mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-15 12:30:32 -07:00
Merge branch 'main' into abhi/agent-factory.draft
This commit is contained in:
@@ -31,7 +31,7 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"@google/genai": "1.30.0",
|
||||
"@google/genai": "1.41.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
"ansi-escapes": "^7.3.0",
|
||||
|
||||
@@ -47,6 +47,7 @@ const defaultRequestConfirmation: RequestConfirmationCallback = async (
|
||||
message,
|
||||
initial: false,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return response.confirm;
|
||||
};
|
||||
|
||||
|
||||
@@ -21,7 +21,11 @@ import {
|
||||
type MCPServerConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
|
||||
import { type Settings, createTestMergedSettings } from './settings.js';
|
||||
import {
|
||||
type Settings,
|
||||
type MergedSettings,
|
||||
createTestMergedSettings,
|
||||
} from './settings.js';
|
||||
import * as ServerConfig from '@google/gemini-cli-core';
|
||||
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
@@ -2599,6 +2603,21 @@ describe('loadCliConfig approval mode', () => {
|
||||
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
|
||||
});
|
||||
|
||||
it('should pass planSettings.directory from settings to config', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
general: {
|
||||
plan: {
|
||||
directory: '.custom-plans',
|
||||
},
|
||||
},
|
||||
} as unknown as MergedSettings);
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
const plansDir = config.storage.getPlansDir();
|
||||
expect(plansDir).toContain('.custom-plans');
|
||||
});
|
||||
|
||||
// --- Untrusted Folder Scenarios ---
|
||||
describe('when folder is NOT trusted', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -56,7 +56,10 @@ import { resolvePath } from '../utils/resolvePath.js';
|
||||
import { RESUME_LATEST } from '../utils/sessionUtils.js';
|
||||
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import { createPolicyEngineConfig } from './policy.js';
|
||||
import {
|
||||
createPolicyEngineConfig,
|
||||
resolveWorkspacePolicyState,
|
||||
} from './policy.js';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { McpServerEnablementManager } from './mcp/mcpServerEnablement.js';
|
||||
import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensionLoader.js';
|
||||
@@ -692,9 +695,17 @@ export async function loadCliConfig(
|
||||
policyPaths: argv.policy,
|
||||
};
|
||||
|
||||
const { workspacePoliciesDir, policyUpdateConfirmationRequest } =
|
||||
await resolveWorkspacePolicyState({
|
||||
cwd,
|
||||
trustedFolder,
|
||||
interactive,
|
||||
});
|
||||
|
||||
const policyEngineConfig = await createPolicyEngineConfig(
|
||||
effectiveSettings,
|
||||
approvalMode,
|
||||
workspacePoliciesDir,
|
||||
);
|
||||
policyEngineConfig.nonInteractive = !interactive;
|
||||
|
||||
@@ -758,6 +769,7 @@ export async function loadCliConfig(
|
||||
coreTools: settings.tools?.core || undefined,
|
||||
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
|
||||
policyEngineConfig,
|
||||
policyUpdateConfirmationRequest,
|
||||
excludeTools,
|
||||
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
||||
toolCallCommand: settings.tools?.callCommand,
|
||||
@@ -814,6 +826,7 @@ export async function loadCliConfig(
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
plan: settings.experimental?.plan,
|
||||
planSettings: settings.general.plan,
|
||||
enableEventDrivenScheduler: true,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
|
||||
@@ -866,6 +866,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
try {
|
||||
const hooksContent = await fs.promises.readFile(hooksFilePath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const rawHooks = JSON.parse(hooksContent);
|
||||
|
||||
if (
|
||||
|
||||
@@ -224,4 +224,59 @@ describe('ExtensionRegistryClient', () => {
|
||||
'Failed to fetch extensions: Not Found',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not return irrelevant results', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
...mockExtensions,
|
||||
{
|
||||
id: 'dataplex',
|
||||
extensionName: 'dataplex',
|
||||
extensionDescription: 'Connect to Dataplex Universal Catalog...',
|
||||
fullName: 'google-cloud/dataplex',
|
||||
rank: 6,
|
||||
stars: 6,
|
||||
url: '',
|
||||
repoDescription: '',
|
||||
lastUpdated: '',
|
||||
extensionVersion: '1.0.0',
|
||||
avatarUrl: '',
|
||||
hasMCP: false,
|
||||
hasContext: false,
|
||||
isGoogleOwned: true,
|
||||
licenseKey: '',
|
||||
hasHooks: false,
|
||||
hasCustomCommands: false,
|
||||
hasSkills: false,
|
||||
},
|
||||
{
|
||||
id: 'conductor',
|
||||
extensionName: 'conductor',
|
||||
extensionDescription: 'A conductor extension that actually matches.',
|
||||
fullName: 'someone/conductor',
|
||||
rank: 100,
|
||||
stars: 100,
|
||||
url: '',
|
||||
repoDescription: '',
|
||||
lastUpdated: '',
|
||||
extensionVersion: '1.0.0',
|
||||
avatarUrl: '',
|
||||
hasMCP: false,
|
||||
hasContext: false,
|
||||
isGoogleOwned: false,
|
||||
licenseKey: '',
|
||||
hasHooks: false,
|
||||
hasCustomCommands: false,
|
||||
hasSkills: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const results = await client.searchExtensions('conductor');
|
||||
const ids = results.map((r) => r.id);
|
||||
|
||||
expect(ids).not.toContain('dataplex');
|
||||
expect(ids).toContain('conductor');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,9 +79,11 @@ export class ExtensionRegistryClient {
|
||||
const fzf = new AsyncFzf(allExtensions, {
|
||||
selector: (ext: RegistryExtension) =>
|
||||
`${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`,
|
||||
fuzzy: 'v2',
|
||||
fuzzy: true,
|
||||
});
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -108,7 +110,6 @@ export class ExtensionRegistryClient {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return (await response.json()) as RegistryExtension[];
|
||||
} catch (error) {
|
||||
// Clear the promise on failure so that subsequent calls can try again
|
||||
ExtensionRegistryClient.fetchPromise = null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -148,13 +148,13 @@ describe('Policy Engine Integration Tests', () => {
|
||||
);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// MCP server allowed (priority 2.1) provides general allow for server
|
||||
// MCP server allowed (priority 2.1) provides general allow for server
|
||||
// MCP server allowed (priority 3.1) provides general allow for server
|
||||
// MCP server allowed (priority 3.1) provides general allow for server
|
||||
expect(
|
||||
(await engine.check({ name: 'my-server__safe-tool' }, undefined))
|
||||
.decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
// But specific tool exclude (priority 2.4) wins over server allow
|
||||
// But specific tool exclude (priority 3.4) wins over server allow
|
||||
expect(
|
||||
(await engine.check({ name: 'my-server__dangerous-tool' }, undefined))
|
||||
.decision,
|
||||
@@ -412,25 +412,25 @@ describe('Policy Engine Integration Tests', () => {
|
||||
|
||||
// Find rules and verify their priorities
|
||||
const blockedToolRule = rules.find((r) => r.toolName === 'blocked-tool');
|
||||
expect(blockedToolRule?.priority).toBe(2.4); // Command line exclude
|
||||
expect(blockedToolRule?.priority).toBe(3.4); // Command line exclude
|
||||
|
||||
const blockedServerRule = rules.find(
|
||||
(r) => r.toolName === 'blocked-server__*',
|
||||
);
|
||||
expect(blockedServerRule?.priority).toBe(2.9); // MCP server exclude
|
||||
expect(blockedServerRule?.priority).toBe(3.9); // MCP server exclude
|
||||
|
||||
const specificToolRule = rules.find(
|
||||
(r) => r.toolName === 'specific-tool',
|
||||
);
|
||||
expect(specificToolRule?.priority).toBe(2.3); // Command line allow
|
||||
expect(specificToolRule?.priority).toBe(3.3); // Command line allow
|
||||
|
||||
const trustedServerRule = rules.find(
|
||||
(r) => r.toolName === 'trusted-server__*',
|
||||
);
|
||||
expect(trustedServerRule?.priority).toBe(2.2); // MCP trusted server
|
||||
expect(trustedServerRule?.priority).toBe(3.2); // MCP trusted server
|
||||
|
||||
const mcpServerRule = rules.find((r) => r.toolName === 'mcp-server__*');
|
||||
expect(mcpServerRule?.priority).toBe(2.1); // MCP allowed server
|
||||
expect(mcpServerRule?.priority).toBe(3.1); // MCP allowed server
|
||||
|
||||
const readOnlyToolRule = rules.find((r) => r.toolName === 'glob');
|
||||
// Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny)
|
||||
@@ -577,16 +577,16 @@ describe('Policy Engine Integration Tests', () => {
|
||||
|
||||
// Verify each rule has the expected priority
|
||||
const tool3Rule = rules.find((r) => r.toolName === 'tool3');
|
||||
expect(tool3Rule?.priority).toBe(2.4); // Excluded tools (user tier)
|
||||
expect(tool3Rule?.priority).toBe(3.4); // Excluded tools (user tier)
|
||||
|
||||
const server2Rule = rules.find((r) => r.toolName === 'server2__*');
|
||||
expect(server2Rule?.priority).toBe(2.9); // Excluded servers (user tier)
|
||||
expect(server2Rule?.priority).toBe(3.9); // Excluded servers (user tier)
|
||||
|
||||
const tool1Rule = rules.find((r) => r.toolName === 'tool1');
|
||||
expect(tool1Rule?.priority).toBe(2.3); // Allowed tools (user tier)
|
||||
expect(tool1Rule?.priority).toBe(3.3); // Allowed tools (user tier)
|
||||
|
||||
const server1Rule = rules.find((r) => r.toolName === 'server1__*');
|
||||
expect(server1Rule?.priority).toBe(2.1); // Allowed servers (user tier)
|
||||
expect(server1Rule?.priority).toBe(3.1); // Allowed servers (user tier)
|
||||
|
||||
const globRule = rules.find((r) => r.toolName === 'glob');
|
||||
// Priority 70 in default tier → 1.07
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { resolveWorkspacePolicyState } from './policy.js';
|
||||
import { writeToStderr } from '@google/gemini-cli-core';
|
||||
|
||||
// Mock debugLogger to avoid noise in test output
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
debugLogger: {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
writeToStderr: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('resolveWorkspacePolicyState', () => {
|
||||
let tempDir: string;
|
||||
let workspaceDir: string;
|
||||
let policiesDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a temporary directory for the test
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-'));
|
||||
// Redirect GEMINI_CLI_HOME to the temp directory to isolate integrity storage
|
||||
vi.stubEnv('GEMINI_CLI_HOME', tempDir);
|
||||
|
||||
workspaceDir = path.join(tempDir, 'workspace');
|
||||
fs.mkdirSync(workspaceDir);
|
||||
policiesDir = path.join(workspaceDir, '.gemini', 'policies');
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temporary directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should return empty state if folder is not trusted', async () => {
|
||||
const result = await resolveWorkspacePolicyState({
|
||||
cwd: workspaceDir,
|
||||
trustedFolder: false,
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
workspacePoliciesDir: undefined,
|
||||
policyUpdateConfirmationRequest: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return policy directory if integrity matches', async () => {
|
||||
// Set up policies directory with a file
|
||||
fs.mkdirSync(policiesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
|
||||
|
||||
// First call to establish integrity (interactive accept)
|
||||
const firstResult = await resolveWorkspacePolicyState({
|
||||
cwd: workspaceDir,
|
||||
trustedFolder: true,
|
||||
interactive: true,
|
||||
});
|
||||
expect(firstResult.policyUpdateConfirmationRequest).toBeDefined();
|
||||
|
||||
// Establish integrity manually as if accepted
|
||||
const { PolicyIntegrityManager } = await import('@google/gemini-cli-core');
|
||||
const integrityManager = new PolicyIntegrityManager();
|
||||
await integrityManager.acceptIntegrity(
|
||||
'workspace',
|
||||
workspaceDir,
|
||||
firstResult.policyUpdateConfirmationRequest!.newHash,
|
||||
);
|
||||
|
||||
// Second call should match
|
||||
const result = await resolveWorkspacePolicyState({
|
||||
cwd: workspaceDir,
|
||||
trustedFolder: true,
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
expect(result.workspacePoliciesDir).toBe(policiesDir);
|
||||
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined if integrity is NEW but fileCount is 0', async () => {
|
||||
const result = await resolveWorkspacePolicyState({
|
||||
cwd: workspaceDir,
|
||||
trustedFolder: true,
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
expect(result.workspacePoliciesDir).toBeUndefined();
|
||||
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return confirmation request if changed in interactive mode', async () => {
|
||||
fs.mkdirSync(policiesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
|
||||
|
||||
const result = await resolveWorkspacePolicyState({
|
||||
cwd: workspaceDir,
|
||||
trustedFolder: true,
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
expect(result.workspacePoliciesDir).toBeUndefined();
|
||||
expect(result.policyUpdateConfirmationRequest).toEqual({
|
||||
scope: 'workspace',
|
||||
identifier: workspaceDir,
|
||||
policyDir: policiesDir,
|
||||
newHash: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('should warn and auto-accept if changed in non-interactive mode', async () => {
|
||||
fs.mkdirSync(policiesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
|
||||
|
||||
const result = await resolveWorkspacePolicyState({
|
||||
cwd: workspaceDir,
|
||||
trustedFolder: true,
|
||||
interactive: false,
|
||||
});
|
||||
|
||||
expect(result.workspacePoliciesDir).toBe(policiesDir);
|
||||
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
|
||||
expect(writeToStderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Automatically accepting and loading'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,12 +12,18 @@ import {
|
||||
type PolicySettings,
|
||||
createPolicyEngineConfig as createCorePolicyEngineConfig,
|
||||
createPolicyUpdater as createCorePolicyUpdater,
|
||||
PolicyIntegrityManager,
|
||||
IntegrityStatus,
|
||||
Storage,
|
||||
type PolicyUpdateConfirmationRequest,
|
||||
writeToStderr,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type Settings } from './settings.js';
|
||||
|
||||
export async function createPolicyEngineConfig(
|
||||
settings: Settings,
|
||||
approvalMode: ApprovalMode,
|
||||
workspacePoliciesDir?: string,
|
||||
): Promise<PolicyEngineConfig> {
|
||||
// Explicitly construct PolicySettings from Settings to ensure type safety
|
||||
// and avoid accidental leakage of other settings properties.
|
||||
@@ -26,6 +32,7 @@ export async function createPolicyEngineConfig(
|
||||
tools: settings.tools,
|
||||
mcpServers: settings.mcpServers,
|
||||
policyPaths: settings.policyPaths,
|
||||
workspacePoliciesDir,
|
||||
};
|
||||
|
||||
return createCorePolicyEngineConfig(policySettings, approvalMode);
|
||||
@@ -34,6 +41,72 @@ export async function createPolicyEngineConfig(
|
||||
export function createPolicyUpdater(
|
||||
policyEngine: PolicyEngine,
|
||||
messageBus: MessageBus,
|
||||
storage: Storage,
|
||||
) {
|
||||
return createCorePolicyUpdater(policyEngine, messageBus);
|
||||
return createCorePolicyUpdater(policyEngine, messageBus, storage);
|
||||
}
|
||||
|
||||
export interface WorkspacePolicyState {
|
||||
workspacePoliciesDir?: string;
|
||||
policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the workspace policy state by checking folder trust and policy integrity.
|
||||
*/
|
||||
export async function resolveWorkspacePolicyState(options: {
|
||||
cwd: string;
|
||||
trustedFolder: boolean;
|
||||
interactive: boolean;
|
||||
}): Promise<WorkspacePolicyState> {
|
||||
const { cwd, trustedFolder, interactive } = options;
|
||||
|
||||
let workspacePoliciesDir: string | undefined;
|
||||
let policyUpdateConfirmationRequest:
|
||||
| PolicyUpdateConfirmationRequest
|
||||
| undefined;
|
||||
|
||||
if (trustedFolder) {
|
||||
const potentialWorkspacePoliciesDir = new Storage(
|
||||
cwd,
|
||||
).getWorkspacePoliciesDir();
|
||||
const integrityManager = new PolicyIntegrityManager();
|
||||
const integrityResult = await integrityManager.checkIntegrity(
|
||||
'workspace',
|
||||
cwd,
|
||||
potentialWorkspacePoliciesDir,
|
||||
);
|
||||
|
||||
if (integrityResult.status === IntegrityStatus.MATCH) {
|
||||
workspacePoliciesDir = potentialWorkspacePoliciesDir;
|
||||
} else if (
|
||||
integrityResult.status === IntegrityStatus.NEW &&
|
||||
integrityResult.fileCount === 0
|
||||
) {
|
||||
// No workspace policies found
|
||||
workspacePoliciesDir = undefined;
|
||||
} else if (interactive) {
|
||||
// Policies changed or are new, and we are in interactive mode
|
||||
policyUpdateConfirmationRequest = {
|
||||
scope: 'workspace',
|
||||
identifier: cwd,
|
||||
policyDir: potentialWorkspacePoliciesDir,
|
||||
newHash: integrityResult.hash,
|
||||
};
|
||||
} else {
|
||||
// Non-interactive mode: warn and automatically accept/load
|
||||
await integrityManager.acceptIntegrity(
|
||||
'workspace',
|
||||
cwd,
|
||||
integrityResult.hash,
|
||||
);
|
||||
workspacePoliciesDir = potentialWorkspacePoliciesDir;
|
||||
// debugLogger.warn here doesn't show up in the terminal. It is showing up only in debug mode on the debug console
|
||||
writeToStderr(
|
||||
'WARNING: Workspace policies changed or are new. Automatically accepting and loading them in non-interactive mode.\n',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { workspacePoliciesDir, policyUpdateConfirmationRequest };
|
||||
}
|
||||
|
||||
@@ -107,6 +107,16 @@ describe('SettingsSchema', () => {
|
||||
).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should have plan nested properties', () => {
|
||||
expect(
|
||||
getSettingsSchema().general?.properties?.plan?.properties?.directory,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
getSettingsSchema().general?.properties?.plan?.properties?.directory
|
||||
.type,
|
||||
).toBe('string');
|
||||
});
|
||||
|
||||
it('should have fileFiltering nested properties', () => {
|
||||
expect(
|
||||
getSettingsSchema().context.properties.fileFiltering.properties
|
||||
|
||||
@@ -266,6 +266,27 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
plan: {
|
||||
type: 'object',
|
||||
label: 'Plan',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Planning features configuration.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
directory: {
|
||||
type: 'string',
|
||||
label: 'Plan Directory',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: undefined as string | undefined,
|
||||
description:
|
||||
'The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
enablePromptCompletion: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Prompt Completion',
|
||||
@@ -1313,6 +1334,7 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
useWriteTodos: {
|
||||
type: 'boolean',
|
||||
label: 'Use WriteTodos',
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import { loadCliConfig, type CliArgs } from './config.js';
|
||||
import { createTestMergedSettings } from './settings.js';
|
||||
import * as ServerConfig from '@google/gemini-cli-core';
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('./trustedFolders.js', () => ({
|
||||
isWorkspaceTrusted: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCheckIntegrity = vi.fn();
|
||||
const mockAcceptIntegrity = vi.fn();
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async () => {
|
||||
const actual = await vi.importActual<typeof ServerConfig>(
|
||||
'@google/gemini-cli-core',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
|
||||
memoryContent: '',
|
||||
fileCount: 0,
|
||||
filePaths: [],
|
||||
}),
|
||||
createPolicyEngineConfig: vi.fn().mockResolvedValue({
|
||||
rules: [],
|
||||
checkers: [],
|
||||
}),
|
||||
getVersion: vi.fn().mockResolvedValue('test-version'),
|
||||
PolicyIntegrityManager: vi.fn().mockImplementation(() => ({
|
||||
checkIntegrity: mockCheckIntegrity,
|
||||
acceptIntegrity: mockAcceptIntegrity,
|
||||
})),
|
||||
IntegrityStatus: { MATCH: 'match', NEW: 'new', MISMATCH: 'mismatch' },
|
||||
debugLogger: {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
isHeadlessMode: vi.fn().mockReturnValue(false), // Default to interactive
|
||||
};
|
||||
});
|
||||
|
||||
describe('Workspace-Level Policy CLI Integration', () => {
|
||||
const MOCK_CWD = process.cwd();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default to MATCH for existing tests
|
||||
mockCheckIntegrity.mockResolvedValue({
|
||||
status: 'match',
|
||||
hash: 'test-hash',
|
||||
fileCount: 1,
|
||||
});
|
||||
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should have getWorkspacePoliciesDir on Storage class', () => {
|
||||
const storage = new ServerConfig.Storage(MOCK_CWD);
|
||||
expect(storage.getWorkspacePoliciesDir).toBeDefined();
|
||||
expect(typeof storage.getWorkspacePoliciesDir).toBe('function');
|
||||
});
|
||||
|
||||
it('should pass workspacePoliciesDir to createPolicyEngineConfig when folder is trusted', async () => {
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = { query: 'test' } as unknown as CliArgs;
|
||||
|
||||
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
|
||||
|
||||
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspacePoliciesDir: expect.stringContaining(
|
||||
path.join('.gemini', 'policies'),
|
||||
),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT pass workspacePoliciesDir to createPolicyEngineConfig when folder is NOT trusted', async () => {
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = { query: 'test' } as unknown as CliArgs;
|
||||
|
||||
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
|
||||
|
||||
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspacePoliciesDir: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT pass workspacePoliciesDir if integrity is NEW but fileCount is 0', async () => {
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
mockCheckIntegrity.mockResolvedValue({
|
||||
status: 'new',
|
||||
hash: 'hash',
|
||||
fileCount: 0,
|
||||
});
|
||||
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = { query: 'test' } as unknown as CliArgs;
|
||||
|
||||
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
|
||||
|
||||
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspacePoliciesDir: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should automatically accept and load workspacePoliciesDir if integrity MISMATCH in non-interactive mode', async () => {
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
mockCheckIntegrity.mockResolvedValue({
|
||||
status: 'mismatch',
|
||||
hash: 'new-hash',
|
||||
fileCount: 1,
|
||||
});
|
||||
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(true); // Non-interactive
|
||||
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = { prompt: 'do something' } as unknown as CliArgs;
|
||||
|
||||
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
|
||||
|
||||
expect(mockAcceptIntegrity).toHaveBeenCalledWith(
|
||||
'workspace',
|
||||
MOCK_CWD,
|
||||
'new-hash',
|
||||
);
|
||||
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspacePoliciesDir: expect.stringContaining(
|
||||
path.join('.gemini', 'policies'),
|
||||
),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set policyUpdateConfirmationRequest if integrity MISMATCH in interactive mode', async () => {
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
mockCheckIntegrity.mockResolvedValue({
|
||||
status: 'mismatch',
|
||||
hash: 'new-hash',
|
||||
fileCount: 1,
|
||||
});
|
||||
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive
|
||||
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = {
|
||||
query: 'test',
|
||||
promptInteractive: 'test',
|
||||
} as unknown as CliArgs;
|
||||
|
||||
const config = await loadCliConfig(settings, 'test-session', argv, {
|
||||
cwd: MOCK_CWD,
|
||||
});
|
||||
|
||||
expect(config.getPolicyUpdateConfirmationRequest()).toEqual({
|
||||
scope: 'workspace',
|
||||
identifier: MOCK_CWD,
|
||||
policyDir: expect.stringContaining(path.join('.gemini', 'policies')),
|
||||
newHash: 'new-hash',
|
||||
});
|
||||
// In interactive mode without accept flag, it waits for user confirmation (handled by UI),
|
||||
// so it currently DOES NOT pass the directory to createPolicyEngineConfig yet.
|
||||
// The UI will handle the confirmation and reload/update.
|
||||
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspacePoliciesDir: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set policyUpdateConfirmationRequest if integrity is NEW with files (first time seen) in interactive mode', async () => {
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
mockCheckIntegrity.mockResolvedValue({
|
||||
status: 'new',
|
||||
hash: 'new-hash',
|
||||
fileCount: 5,
|
||||
});
|
||||
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive
|
||||
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = { query: 'test' } as unknown as CliArgs;
|
||||
|
||||
const config = await loadCliConfig(settings, 'test-session', argv, {
|
||||
cwd: MOCK_CWD,
|
||||
});
|
||||
|
||||
expect(config.getPolicyUpdateConfirmationRequest()).toEqual({
|
||||
scope: 'workspace',
|
||||
identifier: MOCK_CWD,
|
||||
policyDir: expect.stringContaining(path.join('.gemini', 'policies')),
|
||||
newHash: 'new-hash',
|
||||
});
|
||||
|
||||
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspacePoliciesDir: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,8 @@ import { appEvents, AppEvent } from './utils/events.js';
|
||||
import {
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
type StartupWarning,
|
||||
WarningPriority,
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
AuthType,
|
||||
@@ -1193,7 +1195,9 @@ describe('startInteractiveUI', () => {
|
||||
},
|
||||
},
|
||||
} as LoadedSettings;
|
||||
const mockStartupWarnings = ['warning1'];
|
||||
const mockStartupWarnings: StartupWarning[] = [
|
||||
{ id: 'w1', message: 'warning1', priority: WarningPriority.High },
|
||||
];
|
||||
const mockWorkspaceRoot = '/root';
|
||||
const mockInitializationResult = {
|
||||
authError: null,
|
||||
@@ -1226,7 +1230,7 @@ describe('startInteractiveUI', () => {
|
||||
async function startTestInteractiveUI(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
startupWarnings: string[],
|
||||
startupWarnings: StartupWarning[],
|
||||
workspaceRoot: string,
|
||||
resumedSessionData: ResumedSessionData | undefined,
|
||||
initializationResult: InitializationResult,
|
||||
|
||||
+33
-16
@@ -11,6 +11,7 @@ import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
import * as cliConfig from './config/config.js';
|
||||
import { readStdin } from './utils/readStdin.js';
|
||||
import { basename } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import v8 from 'node:v8';
|
||||
import os from 'node:os';
|
||||
import dns from 'node:dns';
|
||||
@@ -37,6 +38,8 @@ import {
|
||||
cleanupExpiredSessions,
|
||||
} from './utils/sessionCleanup.js';
|
||||
import {
|
||||
type StartupWarning,
|
||||
WarningPriority,
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
type OutputPayload,
|
||||
@@ -99,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';
|
||||
@@ -180,7 +184,7 @@ ${reason.stack}`
|
||||
export async function startInteractiveUI(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
startupWarnings: string[],
|
||||
startupWarnings: StartupWarning[],
|
||||
workspaceRoot: string = process.cwd(),
|
||||
resumedSessionData: ResumedSessionData | undefined,
|
||||
initializationResult: InitializationResult,
|
||||
@@ -235,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>
|
||||
@@ -580,7 +586,7 @@ export async function main() {
|
||||
|
||||
const policyEngine = config.getPolicyEngine();
|
||||
const messageBus = config.getMessageBus();
|
||||
createPolicyUpdater(policyEngine, messageBus);
|
||||
createPolicyUpdater(policyEngine, messageBus, config.storage);
|
||||
|
||||
// Register SessionEnd hook to fire on graceful exit
|
||||
// This runs before telemetry shutdown in runExitCleanup()
|
||||
@@ -668,9 +674,20 @@ export async function main() {
|
||||
}
|
||||
|
||||
let input = config.getQuestion();
|
||||
const startupWarnings = [
|
||||
...(await getStartupWarnings()),
|
||||
...(await getUserStartupWarnings(settings.merged)),
|
||||
const useAlternateBuffer = shouldEnterAlternateScreen(
|
||||
isAlternateBufferEnabled(settings),
|
||||
config.getScreenReader(),
|
||||
);
|
||||
const rawStartupWarnings = await getStartupWarnings();
|
||||
const startupWarnings: StartupWarning[] = [
|
||||
...rawStartupWarnings.map((message) => ({
|
||||
id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`,
|
||||
message,
|
||||
priority: WarningPriority.High,
|
||||
})),
|
||||
...(await getUserStartupWarnings(settings.merged, undefined, {
|
||||
isAlternateBuffer: useAlternateBuffer,
|
||||
})),
|
||||
];
|
||||
|
||||
// Handle --resume flag
|
||||
|
||||
@@ -217,13 +217,14 @@ export class AppRig {
|
||||
}
|
||||
|
||||
private stubRefreshAuth() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
|
||||
const gcConfig = this.config as any;
|
||||
gcConfig.refreshAuth = async (authMethod: AuthType) => {
|
||||
gcConfig.modelAvailabilityService.reset();
|
||||
|
||||
const newContentGeneratorConfig = {
|
||||
authType: authMethod,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
proxy: gcConfig.getProxy(),
|
||||
apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key',
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { TextBuffer } from '../ui/components/shared/text-buffer.js';
|
||||
const invalidCharsRegex = /[\b\x1b]/;
|
||||
|
||||
function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
|
||||
// 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-assignment
|
||||
const { isNot } = this as any;
|
||||
let pass = true;
|
||||
const invalidLines: Array<{ line: number; content: string }> = [];
|
||||
|
||||
@@ -45,7 +45,7 @@ export const createMockCommandContext = (
|
||||
forScope: vi.fn().mockReturnValue({ settings: {} }),
|
||||
} as unknown as LoadedSettings,
|
||||
git: undefined as GitService | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
logger: {
|
||||
log: vi.fn(),
|
||||
logMessage: vi.fn(),
|
||||
@@ -54,7 +54,7 @@ export const createMockCommandContext = (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any, // Cast because Logger is a class.
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
@@ -94,11 +94,14 @@ export const createMockCommandContext = (
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const merge = (target: any, source: any): any => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const output = { ...target };
|
||||
|
||||
for (const key in source) {
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const sourceValue = source[key];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const targetValue = output[key];
|
||||
|
||||
if (
|
||||
@@ -106,9 +109,11 @@ export const createMockCommandContext = (
|
||||
Object.prototype.toString.call(sourceValue) === '[object Object]' &&
|
||||
Object.prototype.toString.call(targetValue) === '[object Object]'
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
output[key] = merge(targetValue, sourceValue);
|
||||
} else {
|
||||
// If not, we do a direct assignment. This preserves Date objects and others.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
output[key] = sourceValue;
|
||||
}
|
||||
}
|
||||
@@ -116,5 +121,6 @@ export const createMockCommandContext = (
|
||||
return output;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return merge(defaultMocks, overrides);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render as inkRenderDirect, type Instance as InkInstance } from 'ink';
|
||||
import {
|
||||
render as inkRenderDirect,
|
||||
type Instance as InkInstance,
|
||||
type RenderOptions,
|
||||
} from 'ink';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Box } from 'ink';
|
||||
import type React from 'react';
|
||||
@@ -31,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';
|
||||
@@ -68,6 +79,25 @@ type TerminalState = {
|
||||
rows: number;
|
||||
};
|
||||
|
||||
type RenderMetrics = Parameters<NonNullable<RenderOptions['onRender']>>[0];
|
||||
|
||||
interface InkRenderMetrics extends RenderMetrics {
|
||||
output: string;
|
||||
staticOutput?: string;
|
||||
}
|
||||
|
||||
function isInkRenderMetrics(
|
||||
metrics: RenderMetrics,
|
||||
): metrics is InkRenderMetrics {
|
||||
const m = metrics as Record<string, unknown>;
|
||||
return (
|
||||
typeof m === 'object' &&
|
||||
m !== null &&
|
||||
'output' in m &&
|
||||
typeof m['output'] === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
class XtermStdout extends EventEmitter {
|
||||
private state: TerminalState;
|
||||
private pendingWrites = 0;
|
||||
@@ -312,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[] = [];
|
||||
@@ -320,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
|
||||
@@ -357,8 +392,10 @@ export const render = (
|
||||
debug: false,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
onRender: (metrics: { output: string; staticOutput?: string }) => {
|
||||
stdout.onRender(metrics.staticOutput ?? '', metrics.output);
|
||||
onRender: (metrics: RenderMetrics) => {
|
||||
if (isInkRenderMetrics(metrics)) {
|
||||
stdout.onRender(metrics.staticOutput ?? '', metrics.output);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -506,6 +543,7 @@ const mockUIActions: UIActions = {
|
||||
vimHandleInput: vi.fn(),
|
||||
handleIdePromptComplete: vi.fn(),
|
||||
handleFolderTrustSelect: vi.fn(),
|
||||
setIsPolicyUpdateDialogOpen: vi.fn(),
|
||||
setConstrainHeight: vi.fn(),
|
||||
onEscapePromptChange: vi.fn(),
|
||||
refreshStatic: vi.fn(),
|
||||
@@ -536,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,
|
||||
{
|
||||
@@ -637,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}>
|
||||
@@ -649,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>
|
||||
@@ -692,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),
|
||||
};
|
||||
|
||||
@@ -46,6 +46,7 @@ export const createMockSettings = (
|
||||
workspace,
|
||||
isTrusted,
|
||||
errors,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
merged: mergedOverride,
|
||||
...settingsOverrides
|
||||
} = overrides;
|
||||
@@ -75,7 +76,7 @@ export const createMockSettings = (
|
||||
// Assign any function overrides (e.g., vi.fn() for methods)
|
||||
for (const key in overrides) {
|
||||
if (typeof overrides[key] === 'function') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
(loaded as any)[key] = overrides[key];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
CoreEvent,
|
||||
type UserFeedbackPayload,
|
||||
type ResumedSessionData,
|
||||
type StartupWarning,
|
||||
WarningPriority,
|
||||
AuthType,
|
||||
type AgentDefinition,
|
||||
CoreToolCallStatus,
|
||||
@@ -103,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) => {
|
||||
@@ -118,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;
|
||||
}
|
||||
|
||||
@@ -227,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;
|
||||
@@ -248,18 +260,20 @@ describe('AppContainer State Management', () => {
|
||||
config?: Config;
|
||||
version?: string;
|
||||
initResult?: InitializationResult;
|
||||
startupWarnings?: string[];
|
||||
startupWarnings?: StartupWarning[];
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
} = {}) => (
|
||||
<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>
|
||||
);
|
||||
@@ -501,7 +515,18 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
it('renders with startup warnings', async () => {
|
||||
const startupWarnings = ['Warning 1', 'Warning 2'];
|
||||
const startupWarnings: StartupWarning[] = [
|
||||
{
|
||||
id: 'w1',
|
||||
message: 'Warning 1',
|
||||
priority: WarningPriority.High,
|
||||
},
|
||||
{
|
||||
id: 'w2',
|
||||
message: 'Warning 2',
|
||||
priority: WarningPriority.High,
|
||||
},
|
||||
];
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
@@ -2674,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>
|
||||
);
|
||||
@@ -3290,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(
|
||||
|
||||
@@ -41,6 +41,7 @@ import { checkPermissions } from './hooks/atCommandProcessor.js';
|
||||
import { MessageType, StreamingState } from './types.js';
|
||||
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
|
||||
import {
|
||||
type StartupWarning,
|
||||
type EditorType,
|
||||
type Config,
|
||||
type IdeInfo,
|
||||
@@ -94,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';
|
||||
@@ -150,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';
|
||||
@@ -186,7 +192,7 @@ function isToolAwaitingConfirmation(
|
||||
|
||||
interface AppContainerProps {
|
||||
config: Config;
|
||||
startupWarnings?: string[];
|
||||
startupWarnings?: StartupWarning[];
|
||||
version: string;
|
||||
initializationResult: InitializationResult;
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
@@ -213,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({
|
||||
@@ -261,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('');
|
||||
@@ -1188,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 =
|
||||
@@ -1246,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);
|
||||
|
||||
@@ -1424,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
|
||||
>();
|
||||
@@ -1436,8 +1521,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
type: TransientMessageType;
|
||||
}>(WARNING_PROMPT_DURATION_MS);
|
||||
|
||||
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
|
||||
useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
|
||||
const {
|
||||
isFolderTrustDialogOpen,
|
||||
discoveryResults: folderDiscoveryResults,
|
||||
handleFolderTrustSelect,
|
||||
isRestarting,
|
||||
} = useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
|
||||
|
||||
const policyUpdateConfirmationRequest =
|
||||
config.getPolicyUpdateConfirmationRequest();
|
||||
const [isPolicyUpdateDialogOpen, setIsPolicyUpdateDialogOpen] = useState(
|
||||
!!policyUpdateConfirmationRequest,
|
||||
);
|
||||
const {
|
||||
needsRestart: ideNeedsRestart,
|
||||
restartReason: ideTrustRestartReason,
|
||||
@@ -1644,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)) {
|
||||
@@ -1687,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) ||
|
||||
@@ -1910,6 +2029,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
(shouldShowRetentionWarning && retentionCheckComplete) ||
|
||||
shouldShowIdePrompt ||
|
||||
isFolderTrustDialogOpen ||
|
||||
isPolicyUpdateDialogOpen ||
|
||||
adminSettingsChanged ||
|
||||
!!commandConfirmationRequest ||
|
||||
!!authConsentRequest ||
|
||||
@@ -2066,8 +2186,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const fetchBannerTexts = async () => {
|
||||
const [defaultBanner, warningBanner] = await Promise.all([
|
||||
// TODO: temporarily disabling the banner, it will be re-added.
|
||||
'',
|
||||
config.getBannerTextNoCapacityIssues(),
|
||||
config.getBannerTextCapacityIssues(),
|
||||
]);
|
||||
|
||||
@@ -2137,6 +2256,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isResuming,
|
||||
shouldShowIdePrompt,
|
||||
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
|
||||
folderDiscoveryResults,
|
||||
isPolicyUpdateDialogOpen,
|
||||
policyUpdateConfirmationRequest,
|
||||
isTrustedFolder,
|
||||
constrainHeight,
|
||||
showErrorDetails,
|
||||
@@ -2204,6 +2326,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isBackgroundShellListOpen,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
hintMode:
|
||||
config.isModelSteeringEnabled() &&
|
||||
isToolExecuting([
|
||||
@@ -2259,6 +2382,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isResuming,
|
||||
shouldShowIdePrompt,
|
||||
isFolderTrustDialogOpen,
|
||||
folderDiscoveryResults,
|
||||
isPolicyUpdateDialogOpen,
|
||||
policyUpdateConfirmationRequest,
|
||||
isTrustedFolder,
|
||||
constrainHeight,
|
||||
showErrorDetails,
|
||||
@@ -2327,6 +2453,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
backgroundShells,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2356,6 +2483,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
vimHandleInput,
|
||||
handleIdePromptComplete,
|
||||
handleFolderTrustSelect,
|
||||
setIsPolicyUpdateDialogOpen,
|
||||
setConstrainHeight,
|
||||
onEscapePromptChange: handleEscapePromptChange,
|
||||
refreshStatic,
|
||||
@@ -2440,6 +2568,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
vimHandleInput,
|
||||
handleIdePromptComplete,
|
||||
handleFolderTrustSelect,
|
||||
setIsPolicyUpdateDialogOpen,
|
||||
setConstrainHeight,
|
||||
handleEscapePromptChange,
|
||||
refreshStatic,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
type SlashCommandActionReturn,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import open from 'open';
|
||||
@@ -35,6 +36,7 @@ import { stat } from 'node:fs/promises';
|
||||
import { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js';
|
||||
import { type ConfigLogger } from '../../commands/extensions/utils.js';
|
||||
import { ConfigExtensionDialog } from '../components/ConfigExtensionDialog.js';
|
||||
import { ExtensionRegistryView } from '../components/views/ExtensionRegistryView.js';
|
||||
import React from 'react';
|
||||
|
||||
function showMessageIfNoExtensions(
|
||||
@@ -265,7 +267,28 @@ async function restartAction(
|
||||
}
|
||||
}
|
||||
|
||||
async function exploreAction(context: CommandContext) {
|
||||
async function exploreAction(
|
||||
context: CommandContext,
|
||||
): Promise<SlashCommandActionReturn | void> {
|
||||
const settings = context.services.settings.merged;
|
||||
const useRegistryUI = settings.experimental?.extensionRegistry;
|
||||
|
||||
if (useRegistryUI) {
|
||||
const extensionManager = context.services.config?.getExtensionLoader();
|
||||
if (extensionManager instanceof ExtensionManager) {
|
||||
return {
|
||||
type: 'custom_dialog' as const,
|
||||
component: React.createElement(ExtensionRegistryView, {
|
||||
onSelect: (extension) => {
|
||||
debugLogger.debug(`Selected extension: ${extension.extensionName}`);
|
||||
},
|
||||
onClose: () => context.ui.removeComponent(),
|
||||
extensionManager,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const extensionsUrl = 'https://geminicli.com/extensions/';
|
||||
|
||||
// Only check for NODE_ENV for explicit test mode, not for unit test framework
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('planCommand', () => {
|
||||
getApprovalMode: vi.fn(),
|
||||
getFileSystemService: vi.fn(),
|
||||
storage: {
|
||||
getProjectTempPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
|
||||
getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -43,7 +43,7 @@ export const planCommand: SlashCommand = {
|
||||
try {
|
||||
const content = await processSingleFileContent(
|
||||
approvedPlanPath,
|
||||
config.storage.getProjectTempPlansDir(),
|
||||
config.storage.getPlansDir(),
|
||||
config.getFileSystemService(),
|
||||
);
|
||||
const fileName = path.basename(approvedPlanPath);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { getDisplayString } from '@google/gemini-cli-core';
|
||||
|
||||
interface AboutBoxProps {
|
||||
cliVersion: string;
|
||||
@@ -79,7 +80,9 @@ export const AboutBox: React.FC<AboutBoxProps> = ({
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{modelVersion}</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
{getDisplayString(modelVersion)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
|
||||
@@ -191,7 +191,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
|
||||
{showUiDetails && <TodoTray />}
|
||||
|
||||
<Box marginTop={1} width="100%" flexDirection="column">
|
||||
<Box width="100%" flexDirection="column">
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
|
||||
@@ -37,6 +37,7 @@ import { AgentConfigDialog } from './AgentConfigDialog.js';
|
||||
import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js';
|
||||
import { useCallback } from 'react';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { PolicyUpdateDialog } from './PolicyUpdateDialog.js';
|
||||
|
||||
interface DialogManagerProps {
|
||||
addItem: UseHistoryManagerReturn['addItem'];
|
||||
@@ -163,6 +164,16 @@ export const DialogManager = ({
|
||||
<FolderTrustDialog
|
||||
onSelect={uiActions.handleFolderTrustSelect}
|
||||
isRestarting={uiState.isRestarting}
|
||||
discoveryResults={uiState.folderDiscoveryResults}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isPolicyUpdateDialogOpen) {
|
||||
return (
|
||||
<PolicyUpdateDialog
|
||||
config={config}
|
||||
request={uiState.policyUpdateConfirmationRequest!}
|
||||
onClose={() => uiActions.setIsPolicyUpdateDialogOpen(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
getIdeMode: () => false,
|
||||
isTrustedFolder: () => true,
|
||||
storage: {
|
||||
getProjectTempPlansDir: () => mockPlansDir,
|
||||
getPlansDir: () => mockPlansDir,
|
||||
},
|
||||
getFileSystemService: (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
@@ -429,7 +429,7 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
getIdeMode: () => false,
|
||||
isTrustedFolder: () => true,
|
||||
storage: {
|
||||
getProjectTempPlansDir: () => mockPlansDir,
|
||||
getPlansDir: () => mockPlansDir,
|
||||
},
|
||||
getFileSystemService: (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
|
||||
@@ -65,7 +65,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
|
||||
try {
|
||||
const pathError = await validatePlanPath(
|
||||
planPath,
|
||||
config.storage.getProjectTempPlansDir(),
|
||||
config.storage.getPlansDir(),
|
||||
config.getTargetDir(),
|
||||
);
|
||||
if (ignore) return;
|
||||
@@ -83,7 +83,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
|
||||
|
||||
const result = await processSingleFileContent(
|
||||
planPath,
|
||||
config.storage.getProjectTempPlansDir(),
|
||||
config.storage.getPlansDir(),
|
||||
config.getFileSystemService(),
|
||||
);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ vi.mock('../../utils/processUtils.js', () => ({
|
||||
|
||||
const mockedExit = vi.hoisted(() => vi.fn());
|
||||
const mockedCwd = vi.hoisted(() => vi.fn());
|
||||
const mockedRows = vi.hoisted(() => ({ current: 24 }));
|
||||
|
||||
vi.mock('node:process', async () => {
|
||||
const actual =
|
||||
@@ -29,11 +30,20 @@ vi.mock('node:process', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: () => ({ columns: 80, terminalHeight: mockedRows.current }),
|
||||
}));
|
||||
|
||||
describe('FolderTrustDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
mockedCwd.mockReturnValue('/home/user/project');
|
||||
mockedRows.current = 24;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should render the dialog with title and description', async () => {
|
||||
@@ -42,13 +52,159 @@ describe('FolderTrustDialog', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toContain('Do you trust this folder?');
|
||||
expect(lastFrame()).toContain('Do you trust the files in this folder?');
|
||||
expect(lastFrame()).toContain(
|
||||
'Trusting a folder allows Gemini to execute commands it suggests.',
|
||||
'Trusting a folder allows Gemini CLI to load its local configurations',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should truncate discovery results when they exceed maxDiscoveryHeight', async () => {
|
||||
// maxDiscoveryHeight = 24 - 15 = 9.
|
||||
const discoveryResults = {
|
||||
commands: Array.from({ length: 10 }, (_, i) => `cmd${i}`),
|
||||
mcps: Array.from({ length: 10 }, (_, i) => `mcp${i}`),
|
||||
hooks: Array.from({ length: 10 }, (_, i) => `hook${i}`),
|
||||
skills: Array.from({ length: 10 }, (_, i) => `skill${i}`),
|
||||
settings: Array.from({ length: 10 }, (_, i) => `setting${i}`),
|
||||
discoveryErrors: [],
|
||||
securityWarnings: [],
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<FolderTrustDialog
|
||||
onSelect={vi.fn()}
|
||||
discoveryResults={discoveryResults}
|
||||
/>,
|
||||
{
|
||||
width: 80,
|
||||
useAlternateBuffer: false,
|
||||
uiState: { constrainHeight: true, terminalHeight: 24 },
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('This folder contains:');
|
||||
expect(lastFrame()).toContain('hidden');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should adjust maxHeight based on terminal rows', async () => {
|
||||
mockedRows.current = 14; // maxHeight = 14 - 10 = 4
|
||||
const discoveryResults = {
|
||||
commands: ['cmd1', 'cmd2', 'cmd3', 'cmd4', 'cmd5'],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
settings: [],
|
||||
discoveryErrors: [],
|
||||
securityWarnings: [],
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<FolderTrustDialog
|
||||
onSelect={vi.fn()}
|
||||
discoveryResults={discoveryResults}
|
||||
/>,
|
||||
{
|
||||
width: 80,
|
||||
useAlternateBuffer: false,
|
||||
uiState: { constrainHeight: true, terminalHeight: 14 },
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
// With maxHeight=4, the intro text (4 lines) will take most of the space.
|
||||
// The discovery results will likely be hidden.
|
||||
expect(lastFrame()).toContain('hidden');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should use minimum maxHeight of 4', async () => {
|
||||
mockedRows.current = 8; // 8 - 10 = -2, should use 4
|
||||
const discoveryResults = {
|
||||
commands: ['cmd1', 'cmd2', 'cmd3', 'cmd4', 'cmd5'],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
settings: [],
|
||||
discoveryErrors: [],
|
||||
securityWarnings: [],
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<FolderTrustDialog
|
||||
onSelect={vi.fn()}
|
||||
discoveryResults={discoveryResults}
|
||||
/>,
|
||||
{
|
||||
width: 80,
|
||||
useAlternateBuffer: false,
|
||||
uiState: { constrainHeight: true, terminalHeight: 10 },
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('hidden');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should toggle expansion when global Ctrl+O is handled', async () => {
|
||||
const discoveryResults = {
|
||||
commands: Array.from({ length: 10 }, (_, i) => `cmd${i}`),
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
settings: [],
|
||||
discoveryErrors: [],
|
||||
securityWarnings: [],
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<FolderTrustDialog
|
||||
onSelect={vi.fn()}
|
||||
discoveryResults={discoveryResults}
|
||||
/>,
|
||||
{
|
||||
width: 80,
|
||||
useAlternateBuffer: false,
|
||||
// Initially constrained
|
||||
uiState: { constrainHeight: true, terminalHeight: 24 },
|
||||
},
|
||||
);
|
||||
|
||||
// Initial state: truncated
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Do you trust the files in this folder?');
|
||||
// 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');
|
||||
});
|
||||
|
||||
// We can't easily simulate global Ctrl+O toggle in this unit test
|
||||
// because it's handled in AppContainer.
|
||||
// But we can re-render with constrainHeight: false.
|
||||
const { lastFrame: lastFrameExpanded, unmount: unmountExpanded } =
|
||||
renderWithProviders(
|
||||
<FolderTrustDialog
|
||||
onSelect={vi.fn()}
|
||||
discoveryResults={discoveryResults}
|
||||
/>,
|
||||
{
|
||||
width: 80,
|
||||
useAlternateBuffer: false,
|
||||
uiState: { constrainHeight: false, terminalHeight: 24 },
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrameExpanded()).not.toContain('hidden');
|
||||
expect(lastFrameExpanded()).toContain('- cmd9');
|
||||
expect(lastFrameExpanded()).toContain('- cmd4');
|
||||
});
|
||||
|
||||
unmount();
|
||||
unmountExpanded();
|
||||
});
|
||||
|
||||
it('should display exit message and call process.exit and not call onSelect when escape is pressed', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders(
|
||||
@@ -164,5 +320,153 @@ describe('FolderTrustDialog', () => {
|
||||
expect(lastFrame()).toContain('Trust parent folder ()');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display discovery results when provided', async () => {
|
||||
mockedRows.current = 40; // Increase height to show all results
|
||||
const discoveryResults = {
|
||||
commands: ['cmd1', 'cmd2'],
|
||||
mcps: ['mcp1'],
|
||||
hooks: ['hook1'],
|
||||
skills: ['skill1'],
|
||||
settings: ['general', 'ui'],
|
||||
discoveryErrors: [],
|
||||
securityWarnings: [],
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<FolderTrustDialog
|
||||
onSelect={vi.fn()}
|
||||
discoveryResults={discoveryResults}
|
||||
/>,
|
||||
{ width: 80 },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('This folder contains:');
|
||||
expect(lastFrame()).toContain('• Commands (2):');
|
||||
expect(lastFrame()).toContain('- cmd1');
|
||||
expect(lastFrame()).toContain('- cmd2');
|
||||
expect(lastFrame()).toContain('• MCP Servers (1):');
|
||||
expect(lastFrame()).toContain('- mcp1');
|
||||
expect(lastFrame()).toContain('• Hooks (1):');
|
||||
expect(lastFrame()).toContain('- hook1');
|
||||
expect(lastFrame()).toContain('• Skills (1):');
|
||||
expect(lastFrame()).toContain('- skill1');
|
||||
expect(lastFrame()).toContain('• Setting overrides (2):');
|
||||
expect(lastFrame()).toContain('- general');
|
||||
expect(lastFrame()).toContain('- ui');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display security warnings when provided', async () => {
|
||||
const discoveryResults = {
|
||||
commands: [],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
settings: [],
|
||||
discoveryErrors: [],
|
||||
securityWarnings: ['Dangerous setting detected!'],
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<FolderTrustDialog
|
||||
onSelect={vi.fn()}
|
||||
discoveryResults={discoveryResults}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Security Warnings:');
|
||||
expect(lastFrame()).toContain('Dangerous setting detected!');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display discovery errors when provided', async () => {
|
||||
const discoveryResults = {
|
||||
commands: [],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
settings: [],
|
||||
discoveryErrors: ['Failed to load custom commands'],
|
||||
securityWarnings: [],
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<FolderTrustDialog
|
||||
onSelect={vi.fn()}
|
||||
discoveryResults={discoveryResults}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Discovery Errors:');
|
||||
expect(lastFrame()).toContain('Failed to load custom commands');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should use scrolling instead of truncation when alternate buffer is enabled and expanded', async () => {
|
||||
const discoveryResults = {
|
||||
commands: Array.from({ length: 20 }, (_, i) => `cmd${i}`),
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
settings: [],
|
||||
discoveryErrors: [],
|
||||
securityWarnings: [],
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<FolderTrustDialog
|
||||
onSelect={vi.fn()}
|
||||
discoveryResults={discoveryResults}
|
||||
/>,
|
||||
{
|
||||
width: 80,
|
||||
useAlternateBuffer: true,
|
||||
uiState: { constrainHeight: false, terminalHeight: 15 },
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
// In alternate buffer + expanded, the title should be visible (StickyHeader)
|
||||
expect(lastFrame()).toContain('Do you trust the files in this folder?');
|
||||
// And it should NOT use MaxSizedBox truncation
|
||||
expect(lastFrame()).not.toContain('hidden');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should strip ANSI codes from discovery results', async () => {
|
||||
const ansiRed = '\u001b[31m';
|
||||
const ansiReset = '\u001b[39m';
|
||||
|
||||
const discoveryResults = {
|
||||
commands: [`${ansiRed}cmd-with-ansi${ansiReset}`],
|
||||
mcps: [`${ansiRed}mcp-with-ansi${ansiReset}`],
|
||||
hooks: [`${ansiRed}hook-with-ansi${ansiReset}`],
|
||||
skills: [`${ansiRed}skill-with-ansi${ansiReset}`],
|
||||
settings: [`${ansiRed}setting-with-ansi${ansiReset}`],
|
||||
discoveryErrors: [`${ansiRed}error-with-ansi${ansiReset}`],
|
||||
securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`],
|
||||
};
|
||||
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<FolderTrustDialog
|
||||
onSelect={vi.fn()}
|
||||
discoveryResults={discoveryResults}
|
||||
/>,
|
||||
{ width: 100, uiState: { terminalHeight: 40 } },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('cmd-with-ansi');
|
||||
expect(output).toContain('mcp-with-ansi');
|
||||
expect(output).toContain('hook-with-ansi');
|
||||
expect(output).toContain('skill-with-ansi');
|
||||
expect(output).toContain('setting-with-ansi');
|
||||
expect(output).toContain('error-with-ansi');
|
||||
expect(output).toContain('warning-with-ansi');
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,14 +8,25 @@ import { Box, Text } from 'ink';
|
||||
import type React from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { MaxSizedBox } from './shared/MaxSizedBox.js';
|
||||
import { Scrollable } from './shared/Scrollable.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import * as process from 'node:process';
|
||||
import * as path from 'node:path';
|
||||
import { relaunchApp } from '../../utils/processUtils.js';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import { ExitCodes } from '@google/gemini-cli-core';
|
||||
import {
|
||||
ExitCodes,
|
||||
type FolderDiscoveryResults,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { ShowMoreLines } from './ShowMoreLines.js';
|
||||
import { StickyHeader } from './StickyHeader.js';
|
||||
|
||||
export enum FolderTrustChoice {
|
||||
TRUST_FOLDER = 'trust_folder',
|
||||
@@ -26,13 +37,19 @@ export enum FolderTrustChoice {
|
||||
interface FolderTrustDialogProps {
|
||||
onSelect: (choice: FolderTrustChoice) => void;
|
||||
isRestarting?: boolean;
|
||||
discoveryResults?: FolderDiscoveryResults | null;
|
||||
}
|
||||
|
||||
export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
|
||||
onSelect,
|
||||
isRestarting,
|
||||
discoveryResults,
|
||||
}) => {
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const { terminalHeight, terminalWidth, constrainHeight } = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const isExpanded = !constrainHeight;
|
||||
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
@@ -87,33 +104,197 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
const hasDiscovery =
|
||||
discoveryResults &&
|
||||
(discoveryResults.commands.length > 0 ||
|
||||
discoveryResults.mcps.length > 0 ||
|
||||
discoveryResults.hooks.length > 0 ||
|
||||
discoveryResults.skills.length > 0 ||
|
||||
discoveryResults.settings.length > 0);
|
||||
|
||||
const hasWarnings =
|
||||
discoveryResults && discoveryResults.securityWarnings.length > 0;
|
||||
|
||||
const hasErrors =
|
||||
discoveryResults &&
|
||||
discoveryResults.discoveryErrors &&
|
||||
discoveryResults.discoveryErrors.length > 0;
|
||||
|
||||
const dialogWidth = terminalWidth - 2;
|
||||
const borderColor = theme.status.warning;
|
||||
|
||||
// Header: 3 lines
|
||||
// Options: options.length + 2 lines for margins
|
||||
// Footer: 1 line
|
||||
// Safety margin: 2 lines
|
||||
const overhead = 3 + options.length + 2 + 1 + 2;
|
||||
const scrollableHeight = Math.max(4, terminalHeight - overhead);
|
||||
|
||||
const groups = [
|
||||
{ label: 'Commands', items: discoveryResults?.commands ?? [] },
|
||||
{ label: 'MCP Servers', items: discoveryResults?.mcps ?? [] },
|
||||
{ label: 'Hooks', items: discoveryResults?.hooks ?? [] },
|
||||
{ label: 'Skills', items: discoveryResults?.skills ?? [] },
|
||||
{ label: 'Setting overrides', items: discoveryResults?.settings ?? [] },
|
||||
].filter((g) => g.items.length > 0);
|
||||
|
||||
const discoveryContent = (
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.primary}>
|
||||
Trusting a folder allows Gemini CLI to load its local configurations,
|
||||
including custom commands, hooks, MCP servers, agent skills, and
|
||||
settings. These configurations could execute code on your behalf or
|
||||
change the behavior of the CLI.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{hasErrors && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.status.error} bold>
|
||||
❌ Discovery Errors:
|
||||
</Text>
|
||||
{discoveryResults.discoveryErrors.map((error, i) => (
|
||||
<Box key={i} marginLeft={2}>
|
||||
<Text color={theme.status.error}>• {stripAnsi(error)}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{hasWarnings && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.status.warning} bold>
|
||||
⚠️ Security Warnings:
|
||||
</Text>
|
||||
{discoveryResults.securityWarnings.map((warning, i) => (
|
||||
<Box key={i} marginLeft={2}>
|
||||
<Text color={theme.status.warning}>• {stripAnsi(warning)}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{hasDiscovery && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.text.primary} bold>
|
||||
This folder contains:
|
||||
</Text>
|
||||
{groups.map((group) => (
|
||||
<Box key={group.label} flexDirection="column" marginLeft={2}>
|
||||
<Text color={theme.text.primary} bold>
|
||||
• {group.label} ({group.items.length}):
|
||||
</Text>
|
||||
{group.items.map((item, idx) => (
|
||||
<Box key={idx} marginLeft={2}>
|
||||
<Text color={theme.text.primary}>- {stripAnsi(item)}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const title = (
|
||||
<Text bold color={theme.text.primary}>
|
||||
Do you trust the files in this folder?
|
||||
</Text>
|
||||
);
|
||||
|
||||
const selectOptions = (
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
onSelect={onSelect}
|
||||
isFocused={!isRestarting}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (isAlternateBuffer) {
|
||||
return (
|
||||
<Box flexDirection="column" width={dialogWidth}>
|
||||
<StickyHeader
|
||||
width={dialogWidth}
|
||||
isFirst={true}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={false}
|
||||
>
|
||||
{title}
|
||||
</StickyHeader>
|
||||
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderColor={borderColor}
|
||||
borderStyle="round"
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
width={dialogWidth}
|
||||
>
|
||||
<Scrollable
|
||||
hasFocus={!isRestarting}
|
||||
height={scrollableHeight}
|
||||
width={dialogWidth - 2}
|
||||
>
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{discoveryContent}
|
||||
</Box>
|
||||
</Scrollable>
|
||||
|
||||
<Box paddingX={1} marginY={1}>
|
||||
{selectOptions}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
height={0}
|
||||
width={dialogWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={true}
|
||||
borderColor={borderColor}
|
||||
borderStyle="round"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.warning}
|
||||
borderColor={borderColor}
|
||||
padding={1}
|
||||
marginLeft={1}
|
||||
marginRight={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Do you trust this folder?
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
Trusting a folder allows Gemini to execute commands it suggests.
|
||||
This is a security feature to prevent accidental execution in
|
||||
untrusted directories.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginBottom={1}>{title}</Box>
|
||||
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
onSelect={onSelect}
|
||||
isFocused={!isRestarting}
|
||||
/>
|
||||
<MaxSizedBox
|
||||
maxHeight={isExpanded ? undefined : Math.max(4, terminalHeight - 12)}
|
||||
overflowDirection="bottom"
|
||||
>
|
||||
{discoveryContent}
|
||||
</MaxSizedBox>
|
||||
|
||||
<Box marginTop={1}>{selectOptions}</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<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}>
|
||||
@@ -131,4 +312,10 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
|
||||
)}
|
||||
</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' && (
|
||||
|
||||
@@ -1065,7 +1065,7 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT submit on Enter when an @-path is a perfect match', async () => {
|
||||
it('should submit on Enter when an @-path is a perfect match', async () => {
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
@@ -1085,13 +1085,38 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Should handle autocomplete but NOT submit
|
||||
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
// Should submit directly
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('@file.txt');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT submit on Shift+Enter even if an @-path is a perfect match', async () => {
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'file.txt', value: 'file.txt' }],
|
||||
activeSuggestionIndex: 0,
|
||||
isPerfectMatch: true,
|
||||
completionMode: CompletionMode.AT,
|
||||
});
|
||||
props.buffer.text = '@file.txt';
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
||||
uiActions,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
// Simulate Shift+Enter using CSI u sequence
|
||||
stdin.write('\x1b[13;2u');
|
||||
});
|
||||
|
||||
// Should NOT submit, should call newline instead
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
expect(props.buffer.newline).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should auto-execute commands with autoExecute: true on Enter', async () => {
|
||||
const aboutCommand: SlashCommand = {
|
||||
name: 'about',
|
||||
@@ -2285,6 +2310,36 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should prevent perfect match auto-submission immediately after an unsafe paste', async () => {
|
||||
// isTerminalPasteTrusted will be false due to beforeEach setup.
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
isPerfectMatch: true,
|
||||
completionMode: CompletionMode.AT,
|
||||
});
|
||||
props.buffer.text = '@file.txt';
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
|
||||
// Simulate an unsafe paste of a perfect match
|
||||
await act(async () => {
|
||||
stdin.write(`\x1b[200~@file.txt\x1b[201~`);
|
||||
});
|
||||
|
||||
// Simulate an Enter key press immediately after paste
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
|
||||
// Verify that onSubmit was NOT called due to recent paste protection
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
// It should call newline() instead
|
||||
expect(props.buffer.newline).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should allow submission after unsafe paste protection timeout', async () => {
|
||||
// isTerminalPasteTrusted will be false due to beforeEach setup.
|
||||
props.buffer.text = 'pasted text';
|
||||
|
||||
@@ -890,14 +890,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
// We prioritize execution unless the user is explicitly selecting a different suggestion.
|
||||
if (
|
||||
completion.isPerfectMatch &&
|
||||
completion.completionMode !== CompletionMode.AT &&
|
||||
keyMatchers[Command.RETURN](key) &&
|
||||
keyMatchers[Command.SUBMIT](key) &&
|
||||
recentUnsafePasteTime === null &&
|
||||
(!completion.showSuggestions || completion.activeSuggestionIndex <= 0)
|
||||
) {
|
||||
handleSubmit(buffer.text);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Newline insertion
|
||||
if (keyMatchers[Command.NEWLINE](key)) {
|
||||
buffer.newline();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (completion.showSuggestions) {
|
||||
if (completion.suggestions.length > 1) {
|
||||
if (keyMatchers[Command.COMPLETION_UP](key)) {
|
||||
@@ -1078,12 +1084,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
// Newline insertion
|
||||
if (keyMatchers[Command.NEWLINE](key)) {
|
||||
buffer.newline();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl+A (Home) / Ctrl+E (End)
|
||||
if (keyMatchers[Command.HOME](key)) {
|
||||
buffer.move('home');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -9,11 +9,17 @@ import { act } from 'react';
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
AuthType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core';
|
||||
|
||||
@@ -42,12 +48,14 @@ describe('<ModelDialog />', () => {
|
||||
const mockGetModel = vi.fn();
|
||||
const mockOnClose = vi.fn();
|
||||
const mockGetHasAccessToPreviewModel = vi.fn();
|
||||
const mockGetGemini31LaunchedSync = vi.fn();
|
||||
|
||||
interface MockConfig extends Partial<Config> {
|
||||
setModel: (model: string, isTemporary?: boolean) => void;
|
||||
getModel: () => string;
|
||||
getHasAccessToPreviewModel: () => boolean;
|
||||
getIdeMode: () => boolean;
|
||||
getGemini31LaunchedSync: () => boolean;
|
||||
}
|
||||
|
||||
const mockConfig: MockConfig = {
|
||||
@@ -55,12 +63,14 @@ describe('<ModelDialog />', () => {
|
||||
getModel: mockGetModel,
|
||||
getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel,
|
||||
getIdeMode: () => false,
|
||||
getGemini31LaunchedSync: mockGetGemini31LaunchedSync,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(false);
|
||||
mockGetGemini31LaunchedSync.mockReturnValue(false);
|
||||
|
||||
// Default implementation for getDisplayString
|
||||
mockGetDisplayString.mockImplementation((val: string) => {
|
||||
@@ -70,9 +80,21 @@ describe('<ModelDialog />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const renderComponent = async (configValue = mockConfig as Config) => {
|
||||
const renderComponent = async (
|
||||
configValue = mockConfig as Config,
|
||||
authType = AuthType.LOGIN_WITH_GOOGLE,
|
||||
) => {
|
||||
const settings = createMockSettings({
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: authType,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = renderWithProviders(<ModelDialog onClose={mockOnClose} />, {
|
||||
config: configValue,
|
||||
settings,
|
||||
});
|
||||
await result.waitUntilReady();
|
||||
return result;
|
||||
@@ -87,7 +109,15 @@ describe('<ModelDialog />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('switches to "manual" view when "Manual" is selected', async () => {
|
||||
it('switches to "manual" view when "Manual" is selected and uses getDisplayString for models', async () => {
|
||||
mockGetDisplayString.mockImplementation((val: string) => {
|
||||
if (val === DEFAULT_GEMINI_MODEL) return 'Formatted Pro Model';
|
||||
if (val === DEFAULT_GEMINI_FLASH_MODEL) return 'Formatted Flash Model';
|
||||
if (val === DEFAULT_GEMINI_FLASH_LITE_MODEL)
|
||||
return 'Formatted Lite Model';
|
||||
return val;
|
||||
});
|
||||
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } =
|
||||
await renderComponent();
|
||||
|
||||
@@ -107,9 +137,9 @@ describe('<ModelDialog />', () => {
|
||||
// Should now show manual options
|
||||
await waitFor(() => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(DEFAULT_GEMINI_MODEL);
|
||||
expect(output).toContain(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
expect(output).toContain(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
expect(output).toContain('Formatted Pro Model');
|
||||
expect(output).toContain('Formatted Flash Model');
|
||||
expect(output).toContain('Formatted Lite Model');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
@@ -241,4 +271,103 @@ describe('<ModelDialog />', () => {
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows the preferred manual model in the main view option using getDisplayString', async () => {
|
||||
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL);
|
||||
mockGetDisplayString.mockImplementation((val: string) => {
|
||||
if (val === DEFAULT_GEMINI_MODEL) return 'My Custom Model Display';
|
||||
if (val === 'auto-gemini-2.5') return 'Auto (Gemini 2.5)';
|
||||
return val;
|
||||
});
|
||||
const { lastFrame, unmount } = await renderComponent();
|
||||
|
||||
expect(lastFrame()).toContain('Manual (My Custom Model Display)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('Preview Models', () => {
|
||||
beforeEach(() => {
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('shows Auto (Preview) in main view when access is granted', async () => {
|
||||
const { lastFrame, unmount } = await renderComponent();
|
||||
expect(lastFrame()).toContain('Auto (Preview)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows Gemini 3 models in manual view when Gemini 3.1 is NOT launched', async () => {
|
||||
mockGetGemini31LaunchedSync.mockReturnValue(false);
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } =
|
||||
await renderComponent();
|
||||
|
||||
// Go to manual view
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Manual
|
||||
});
|
||||
await waitUntilReady();
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(PREVIEW_GEMINI_MODEL);
|
||||
expect(output).toContain(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows Gemini 3.1 models in manual view when Gemini 3.1 IS launched', async () => {
|
||||
mockGetGemini31LaunchedSync.mockReturnValue(true);
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } =
|
||||
await renderComponent(mockConfig as Config, AuthType.USE_VERTEX_AI);
|
||||
|
||||
// Go to manual view
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Manual
|
||||
});
|
||||
await waitUntilReady();
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(PREVIEW_GEMINI_3_1_MODEL);
|
||||
expect(output).toContain(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('uses custom tools model when Gemini 3.1 IS launched and auth is Gemini API Key', async () => {
|
||||
mockGetGemini31LaunchedSync.mockReturnValue(true);
|
||||
const { stdin, waitUntilReady, unmount } = await renderComponent(
|
||||
mockConfig as Config,
|
||||
AuthType.USE_GEMINI,
|
||||
);
|
||||
|
||||
// Go to manual view
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Manual
|
||||
});
|
||||
await waitUntilReady();
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Select Gemini 3.1 (first item in preview section)
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetModel).toHaveBeenCalledWith(
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
true,
|
||||
);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
@@ -18,11 +19,14 @@ import {
|
||||
ModelSlashCommandEvent,
|
||||
logModelSlashCommand,
|
||||
getDisplayString,
|
||||
AuthType,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
|
||||
interface ModelDialogProps {
|
||||
onClose: () => void;
|
||||
@@ -30,6 +34,7 @@ interface ModelDialogProps {
|
||||
|
||||
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
const config = useContext(ConfigContext);
|
||||
const settings = useSettings();
|
||||
const [view, setView] = useState<'main' | 'manual'>('main');
|
||||
const [persistMode, setPersistMode] = useState(false);
|
||||
|
||||
@@ -37,6 +42,10 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
const preferredModel = config?.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
|
||||
|
||||
const shouldShowPreviewModels = config?.getHasAccessToPreviewModel();
|
||||
const useGemini31 = config?.getGemini31LaunchedSync?.() ?? false;
|
||||
const selectedAuthType = settings.merged.security.auth.selectedType;
|
||||
const useCustomToolModel =
|
||||
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
|
||||
|
||||
const manualModelSelected = useMemo(() => {
|
||||
const manualModels = [
|
||||
@@ -44,6 +53,8 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
];
|
||||
if (manualModels.includes(preferredModel)) {
|
||||
@@ -83,7 +94,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
{
|
||||
value: 'Manual',
|
||||
title: manualModelSelected
|
||||
? `Manual (${manualModelSelected})`
|
||||
? `Manual (${getDisplayString(manualModelSelected)})`
|
||||
: 'Manual',
|
||||
description: 'Manually select a model',
|
||||
key: 'Manual',
|
||||
@@ -94,49 +105,58 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
list.unshift({
|
||||
value: PREVIEW_GEMINI_MODEL_AUTO,
|
||||
title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO),
|
||||
description:
|
||||
'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
|
||||
description: useGemini31
|
||||
? 'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash'
|
||||
: 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
|
||||
key: PREVIEW_GEMINI_MODEL_AUTO,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}, [shouldShowPreviewModels, manualModelSelected]);
|
||||
}, [shouldShowPreviewModels, manualModelSelected, useGemini31]);
|
||||
|
||||
const manualOptions = useMemo(() => {
|
||||
const list = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL,
|
||||
title: DEFAULT_GEMINI_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_MODEL),
|
||||
key: DEFAULT_GEMINI_MODEL,
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
title: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL),
|
||||
key: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
title: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL),
|
||||
key: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
},
|
||||
];
|
||||
|
||||
if (shouldShowPreviewModels) {
|
||||
const previewProModel = useGemini31
|
||||
? PREVIEW_GEMINI_3_1_MODEL
|
||||
: PREVIEW_GEMINI_MODEL;
|
||||
|
||||
const previewProValue = useCustomToolModel
|
||||
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
|
||||
: previewProModel;
|
||||
|
||||
list.unshift(
|
||||
{
|
||||
value: PREVIEW_GEMINI_MODEL,
|
||||
title: PREVIEW_GEMINI_MODEL,
|
||||
key: PREVIEW_GEMINI_MODEL,
|
||||
value: previewProValue,
|
||||
title: getDisplayString(previewProModel),
|
||||
key: previewProModel,
|
||||
},
|
||||
{
|
||||
value: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
title: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
|
||||
key: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
},
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [shouldShowPreviewModels]);
|
||||
}, [shouldShowPreviewModels, useGemini31, useCustomToolModel]);
|
||||
|
||||
const options = view === 'main' ? mainOptions : manualOptions;
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render, persistentStateMock } from '../../test-utils/render.js';
|
||||
import {
|
||||
persistentStateMock,
|
||||
renderWithProviders,
|
||||
} from '../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { Notifications } from './Notifications.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
@@ -13,6 +18,7 @@ import { useUIState, type UIState } from '../contexts/UIStateContext.js';
|
||||
import { useIsScreenReaderEnabled } from 'ink';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { act } from 'react';
|
||||
import { WarningPriority } from '@google/gemini-cli-core';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../contexts/AppContext.js');
|
||||
@@ -61,22 +67,18 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
...actual,
|
||||
GEMINI_DIR: '.gemini',
|
||||
homedir: () => '/mock/home',
|
||||
WarningPriority: {
|
||||
Low: 'low',
|
||||
High: 'high',
|
||||
},
|
||||
Storage: {
|
||||
...actual.Storage,
|
||||
getGlobalTempDir: () => '/mock/temp',
|
||||
getGlobalSettingsPath: () => '/mock/home/.gemini/settings.json',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/settings.js', () => ({
|
||||
DEFAULT_MODEL_CONFIGS: {},
|
||||
LoadedSettings: class {
|
||||
constructor() {
|
||||
// this.merged = {};
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Notifications', () => {
|
||||
const mockUseAppContext = vi.mocked(useAppContext);
|
||||
const mockUseUIState = vi.mocked(useUIState);
|
||||
@@ -84,9 +86,14 @@ describe('Notifications', () => {
|
||||
const mockFsAccess = vi.mocked(fs.access);
|
||||
const mockFsUnlink = vi.mocked(fs.unlink);
|
||||
|
||||
let settings: LoadedSettings;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
persistentStateMock.reset();
|
||||
settings = createMockSettings({
|
||||
ui: { useAlternateBuffer: true },
|
||||
});
|
||||
mockUseAppContext.mockReturnValue({
|
||||
startupWarnings: [],
|
||||
version: '1.0.0',
|
||||
@@ -100,60 +107,195 @@ describe('Notifications', () => {
|
||||
});
|
||||
|
||||
it('renders nothing when no notifications', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each([[['Warning 1']], [['Warning 1', 'Warning 2']]])(
|
||||
'renders startup warnings: %s',
|
||||
async (warnings) => {
|
||||
mockUseAppContext.mockReturnValue({
|
||||
startupWarnings: warnings,
|
||||
version: '1.0.0',
|
||||
} as AppState);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
warnings.forEach((warning) => {
|
||||
expect(output).toContain(warning);
|
||||
});
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
[[{ id: 'w1', message: 'Warning 1', priority: WarningPriority.High }]],
|
||||
[
|
||||
[
|
||||
{ id: 'w1', message: 'Warning 1', priority: WarningPriority.High },
|
||||
{ id: 'w2', message: 'Warning 2', priority: WarningPriority.High },
|
||||
],
|
||||
],
|
||||
])('renders startup warnings: %s', async (warnings) => {
|
||||
const appState = {
|
||||
startupWarnings: warnings,
|
||||
version: '1.0.0',
|
||||
} as AppState;
|
||||
mockUseAppContext.mockReturnValue(appState);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
appState,
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
warnings.forEach((warning) => {
|
||||
expect(output).toContain(warning.message);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('increments show count for low priority warnings', async () => {
|
||||
const warnings = [
|
||||
{ id: 'low-1', message: 'Low priority 1', priority: WarningPriority.Low },
|
||||
];
|
||||
const appState = {
|
||||
startupWarnings: warnings,
|
||||
version: '1.0.0',
|
||||
} as AppState;
|
||||
mockUseAppContext.mockReturnValue(appState);
|
||||
|
||||
const { waitUntilReady, unmount } = renderWithProviders(<Notifications />, {
|
||||
appState,
|
||||
settings,
|
||||
width: 100,
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(persistentStateMock.set).toHaveBeenCalledWith(
|
||||
'startupWarningCounts',
|
||||
{ 'low-1': 1 },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('filters out low priority warnings that exceeded max show count', async () => {
|
||||
const warnings = [
|
||||
{ id: 'low-1', message: 'Low priority 1', priority: WarningPriority.Low },
|
||||
{
|
||||
id: 'high-1',
|
||||
message: 'High priority 1',
|
||||
priority: WarningPriority.High,
|
||||
},
|
||||
];
|
||||
const appState = {
|
||||
startupWarnings: warnings,
|
||||
version: '1.0.0',
|
||||
} as AppState;
|
||||
mockUseAppContext.mockReturnValue(appState);
|
||||
|
||||
persistentStateMock.setData({
|
||||
startupWarningCounts: { 'low-1': 3 },
|
||||
});
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
appState,
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('Low priority 1');
|
||||
expect(output).toContain('High priority 1');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('dismisses warnings on keypress', async () => {
|
||||
const warnings = [
|
||||
{
|
||||
id: 'high-1',
|
||||
message: 'High priority 1',
|
||||
priority: WarningPriority.High,
|
||||
},
|
||||
];
|
||||
const appState = {
|
||||
startupWarnings: warnings,
|
||||
version: '1.0.0',
|
||||
} as AppState;
|
||||
mockUseAppContext.mockReturnValue(appState);
|
||||
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
appState,
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('High priority 1');
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('a');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame({ allowEmpty: true })).not.toContain('High priority 1');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders init error', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
const uiState = {
|
||||
initError: 'Something went wrong',
|
||||
streamingState: 'idle',
|
||||
updateInfo: null,
|
||||
} as unknown as UIState);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
|
||||
} as unknown as UIState;
|
||||
mockUseUIState.mockReturnValue(uiState);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
uiState,
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not render init error when streaming', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
const uiState = {
|
||||
initError: 'Something went wrong',
|
||||
streamingState: 'responding',
|
||||
updateInfo: null,
|
||||
} as unknown as UIState);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
|
||||
} as unknown as UIState;
|
||||
mockUseUIState.mockReturnValue(uiState);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
uiState,
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders update notification', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
const uiState = {
|
||||
initError: null,
|
||||
streamingState: 'idle',
|
||||
updateInfo: { message: 'Update available' },
|
||||
} as unknown as UIState);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
|
||||
} as unknown as UIState;
|
||||
mockUseUIState.mockReturnValue(uiState);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
uiState,
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
@@ -164,7 +306,13 @@ describe('Notifications', () => {
|
||||
persistentStateMock.setData({ hasSeenScreenReaderNudge: false });
|
||||
mockFsAccess.mockRejectedValue(new Error('No legacy file'));
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toContain('screen reader-friendly view');
|
||||
@@ -182,7 +330,10 @@ describe('Notifications', () => {
|
||||
persistentStateMock.setData({ hasSeenScreenReaderNudge: undefined });
|
||||
mockFsAccess.mockResolvedValue(undefined);
|
||||
|
||||
const { waitUntilReady, unmount } = render(<Notifications />);
|
||||
const { waitUntilReady, unmount } = renderWithProviders(<Notifications />, {
|
||||
settings,
|
||||
width: 100,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await waitUntilReady();
|
||||
@@ -201,7 +352,13 @@ describe('Notifications', () => {
|
||||
mockUseIsScreenReaderEnabled.mockReturnValue(true);
|
||||
persistentStateMock.setData({ hasSeenScreenReaderNudge: true });
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Notifications />,
|
||||
{
|
||||
settings,
|
||||
width: 100,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
|
||||
@@ -5,15 +5,22 @@
|
||||
*/
|
||||
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { useAppContext } from '../contexts/AppContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { UpdateNotification } from './UpdateNotification.js';
|
||||
import { persistentState } from '../../utils/persistentState.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { KeypressPriority } from '../contexts/KeypressContext.js';
|
||||
|
||||
import { GEMINI_DIR, Storage, homedir } from '@google/gemini-cli-core';
|
||||
import {
|
||||
GEMINI_DIR,
|
||||
Storage,
|
||||
homedir,
|
||||
WarningPriority,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
@@ -25,12 +32,13 @@ const screenReaderNudgeFilePath = path.join(
|
||||
'seen_screen_reader_nudge.json',
|
||||
);
|
||||
|
||||
const MAX_STARTUP_WARNING_SHOW_COUNT = 3;
|
||||
|
||||
export const Notifications = () => {
|
||||
const { startupWarnings } = useAppContext();
|
||||
const { initError, streamingState, updateInfo } = useUIState();
|
||||
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
const showStartupWarnings = startupWarnings.length > 0;
|
||||
const showInitError =
|
||||
initError && streamingState !== StreamingState.Responding;
|
||||
|
||||
@@ -38,6 +46,57 @@ export const Notifications = () => {
|
||||
persistentState.get('hasSeenScreenReaderNudge'),
|
||||
);
|
||||
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
// Track if we have already incremented the show count in this session
|
||||
const hasIncrementedRef = useRef(false);
|
||||
|
||||
// Filter warnings based on persistent state count if low priority
|
||||
const visibleWarnings = useMemo(() => {
|
||||
if (dismissed) return [];
|
||||
|
||||
const counts = persistentState.get('startupWarningCounts') || {};
|
||||
return startupWarnings.filter((w) => {
|
||||
if (w.priority === WarningPriority.Low) {
|
||||
const count = counts[w.id] || 0;
|
||||
return count < MAX_STARTUP_WARNING_SHOW_COUNT;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [startupWarnings, dismissed]);
|
||||
|
||||
const showStartupWarnings = visibleWarnings.length > 0;
|
||||
|
||||
// Increment counts for low priority warnings when shown
|
||||
useEffect(() => {
|
||||
if (visibleWarnings.length > 0 && !hasIncrementedRef.current) {
|
||||
const counts = { ...(persistentState.get('startupWarningCounts') || {}) };
|
||||
let changed = false;
|
||||
visibleWarnings.forEach((w) => {
|
||||
if (w.priority === WarningPriority.Low) {
|
||||
counts[w.id] = (counts[w.id] || 0) + 1;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
persistentState.set('startupWarningCounts', counts);
|
||||
}
|
||||
hasIncrementedRef.current = true;
|
||||
}
|
||||
}, [visibleWarnings]);
|
||||
|
||||
const handleKeyPress = useCallback(() => {
|
||||
if (showStartupWarnings) {
|
||||
setDismissed(true);
|
||||
}
|
||||
return false;
|
||||
}, [showStartupWarnings]);
|
||||
|
||||
useKeypress(handleKeyPress, {
|
||||
isActive: showStartupWarnings,
|
||||
priority: KeypressPriority.Critical,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const checkLegacyScreenReaderNudge = async () => {
|
||||
if (hasSeenScreenReaderNudge !== undefined) return;
|
||||
@@ -89,13 +148,13 @@ export const Notifications = () => {
|
||||
{updateInfo && <UpdateNotification message={updateInfo.message} />}
|
||||
{showStartupWarnings && (
|
||||
<Box marginY={1} flexDirection="column">
|
||||
{startupWarnings.map((warning, index) => (
|
||||
{visibleWarnings.map((warning, index) => (
|
||||
<Box key={index} flexDirection="row">
|
||||
<Box width={3}>
|
||||
<Text color={theme.status.warning}>⚠ </Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text color={theme.status.warning}>{warning}</Text>
|
||||
<Text color={theme.status.warning}>{warning.message}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { PolicyUpdateDialog } from './PolicyUpdateDialog.js';
|
||||
import {
|
||||
type Config,
|
||||
type PolicyUpdateConfirmationRequest,
|
||||
PolicyIntegrityManager,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
const { mockAcceptIntegrity } = vi.hoisted(() => ({
|
||||
mockAcceptIntegrity: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock PolicyIntegrityManager
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...original,
|
||||
PolicyIntegrityManager: vi.fn().mockImplementation(() => ({
|
||||
acceptIntegrity: mockAcceptIntegrity,
|
||||
checkIntegrity: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('PolicyUpdateDialog', () => {
|
||||
let mockConfig: Config;
|
||||
let mockRequest: PolicyUpdateConfirmationRequest;
|
||||
let onClose: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
loadWorkspacePolicies: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
mockRequest = {
|
||||
scope: 'workspace',
|
||||
identifier: '/test/workspace/.gemini/policies',
|
||||
policyDir: '/test/workspace/.gemini/policies',
|
||||
newHash: 'test-hash',
|
||||
} as PolicyUpdateConfirmationRequest;
|
||||
|
||||
onClose = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders correctly and matches snapshot', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderWithProviders(
|
||||
<PolicyUpdateDialog
|
||||
config={mockConfig}
|
||||
request={mockRequest}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toMatchSnapshot();
|
||||
expect(output).toContain('New or changed workspace policies detected');
|
||||
expect(output).toContain('Location: /test/workspace/.gemini/policies');
|
||||
expect(output).toContain('Accept and Load');
|
||||
expect(output).toContain('Ignore');
|
||||
});
|
||||
|
||||
it('handles ACCEPT correctly', async () => {
|
||||
const { stdin } = renderWithProviders(
|
||||
<PolicyUpdateDialog
|
||||
config={mockConfig}
|
||||
request={mockRequest}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Accept is the first option, so pressing enter should select it
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(PolicyIntegrityManager).toHaveBeenCalled();
|
||||
expect(mockConfig.loadWorkspacePolicies).toHaveBeenCalledWith(
|
||||
mockRequest.policyDir,
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles IGNORE correctly', async () => {
|
||||
const { stdin } = renderWithProviders(
|
||||
<PolicyUpdateDialog
|
||||
config={mockConfig}
|
||||
request={mockRequest}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Move down to Ignore option
|
||||
await act(async () => {
|
||||
stdin.write('\x1B[B'); // Down arrow
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(PolicyIntegrityManager).not.toHaveBeenCalled();
|
||||
expect(mockConfig.loadWorkspacePolicies).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose when Escape key is pressed', async () => {
|
||||
const { stdin } = renderWithProviders(
|
||||
<PolicyUpdateDialog
|
||||
config={mockConfig}
|
||||
request={mockRequest}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x1B'); // Escape key (matches Command.ESCAPE default)
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type React from 'react';
|
||||
import {
|
||||
type Config,
|
||||
type PolicyUpdateConfirmationRequest,
|
||||
PolicyIntegrityManager,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
|
||||
export enum PolicyUpdateChoice {
|
||||
ACCEPT = 'accept',
|
||||
IGNORE = 'ignore',
|
||||
}
|
||||
|
||||
interface PolicyUpdateDialogProps {
|
||||
config: Config;
|
||||
request: PolicyUpdateConfirmationRequest;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const PolicyUpdateDialog: React.FC<PolicyUpdateDialogProps> = ({
|
||||
config,
|
||||
request,
|
||||
onClose,
|
||||
}) => {
|
||||
const isProcessing = useRef(false);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (choice: PolicyUpdateChoice) => {
|
||||
if (isProcessing.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing.current = true;
|
||||
try {
|
||||
if (choice === PolicyUpdateChoice.ACCEPT) {
|
||||
const integrityManager = new PolicyIntegrityManager();
|
||||
await integrityManager.acceptIntegrity(
|
||||
request.scope,
|
||||
request.identifier,
|
||||
request.newHash,
|
||||
);
|
||||
await config.loadWorkspacePolicies(request.policyDir);
|
||||
}
|
||||
onClose();
|
||||
} finally {
|
||||
isProcessing.current = false;
|
||||
}
|
||||
},
|
||||
[config, request, onClose],
|
||||
);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
onClose();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const options: Array<RadioSelectItem<PolicyUpdateChoice>> = [
|
||||
{
|
||||
label: 'Accept and Load',
|
||||
value: PolicyUpdateChoice.ACCEPT,
|
||||
key: 'accept',
|
||||
},
|
||||
{
|
||||
label: 'Ignore (Use Default Policies)',
|
||||
value: PolicyUpdateChoice.IGNORE,
|
||||
key: 'ignore',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.warning}
|
||||
padding={1}
|
||||
marginLeft={1}
|
||||
marginRight={1}
|
||||
>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
New or changed {request.scope} policies detected
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>Location: {request.identifier}</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
Do you want to accept and load these policies?
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
onSelect={handleSelect}
|
||||
isFocused={true}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@
|
||||
import type React from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { AsyncFzf } from 'fzf';
|
||||
import type { Key } from '../hooks/useKeypress.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type {
|
||||
@@ -31,17 +32,27 @@ import {
|
||||
getEffectiveValue,
|
||||
} from '../../utils/settingsUtils.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import {
|
||||
type SettingsValue,
|
||||
TOGGLE_TYPES,
|
||||
} from '../../config/settingsSchema.js';
|
||||
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
import { useSearchBuffer } from '../hooks/useSearchBuffer.js';
|
||||
import {
|
||||
type SettingsDialogItem,
|
||||
BaseSettingsDialog,
|
||||
type SettingsDialogItem,
|
||||
} from './shared/BaseSettingsDialog.js';
|
||||
import { useFuzzyList } from '../hooks/useFuzzyList.js';
|
||||
|
||||
interface FzfResult {
|
||||
item: string;
|
||||
start: number;
|
||||
end: number;
|
||||
score: number;
|
||||
positions?: number[];
|
||||
}
|
||||
|
||||
interface SettingsDialogProps {
|
||||
settings: LoadedSettings;
|
||||
@@ -70,6 +81,61 @@ export function SettingsDialog({
|
||||
|
||||
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
|
||||
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filteredKeys, setFilteredKeys] = useState<string[]>(() =>
|
||||
getDialogSettingKeys(),
|
||||
);
|
||||
const { fzfInstance, searchMap } = useMemo(() => {
|
||||
const keys = getDialogSettingKeys();
|
||||
const map = new Map<string, string>();
|
||||
const searchItems: string[] = [];
|
||||
|
||||
keys.forEach((key) => {
|
||||
const def = getSettingDefinition(key);
|
||||
if (def?.label) {
|
||||
searchItems.push(def.label);
|
||||
map.set(def.label.toLowerCase(), key);
|
||||
}
|
||||
});
|
||||
|
||||
const fzf = new AsyncFzf(searchItems, {
|
||||
fuzzy: 'v2',
|
||||
casing: 'case-insensitive',
|
||||
});
|
||||
return { fzfInstance: fzf, searchMap: map };
|
||||
}, []);
|
||||
|
||||
// Perform search
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (!searchQuery.trim() || !fzfInstance) {
|
||||
setFilteredKeys(getDialogSettingKeys());
|
||||
return;
|
||||
}
|
||||
|
||||
const doSearch = async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const results = await fzfInstance.find(searchQuery);
|
||||
|
||||
if (!active) return;
|
||||
|
||||
const matchedKeys = new Set<string>();
|
||||
results.forEach((res: FzfResult) => {
|
||||
const key = searchMap.get(res.item.toLowerCase());
|
||||
if (key) matchedKeys.add(key);
|
||||
});
|
||||
setFilteredKeys(Array.from(matchedKeys));
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
doSearch();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [searchQuery, fzfInstance, searchMap]);
|
||||
|
||||
// Local pending settings state for the selected scope
|
||||
const [pendingSettings, setPendingSettings] = useState<Settings>(() =>
|
||||
// Deep clone to avoid mutation
|
||||
@@ -117,8 +183,39 @@ export function SettingsDialog({
|
||||
setShowRestartPrompt(newRestartRequired.size > 0);
|
||||
}, [selectedScope, settings, globalPendingChanges]);
|
||||
|
||||
// Generate items for SearchableList
|
||||
const settingKeys = useMemo(() => getDialogSettingKeys(), []);
|
||||
// Calculate max width for the left column (Label/Description) to keep values aligned or close
|
||||
const maxLabelOrDescriptionWidth = useMemo(() => {
|
||||
const allKeys = getDialogSettingKeys();
|
||||
let max = 0;
|
||||
for (const key of allKeys) {
|
||||
const def = getSettingDefinition(key);
|
||||
if (!def) continue;
|
||||
|
||||
const scopeMessage = getScopeMessageForSetting(
|
||||
key,
|
||||
selectedScope,
|
||||
settings,
|
||||
);
|
||||
const label = def.label || key;
|
||||
const labelFull = label + (scopeMessage ? ` ${scopeMessage}` : '');
|
||||
const lWidth = getCachedStringWidth(labelFull);
|
||||
const dWidth = def.description
|
||||
? getCachedStringWidth(def.description)
|
||||
: 0;
|
||||
|
||||
max = Math.max(max, lWidth, dWidth);
|
||||
}
|
||||
return max;
|
||||
}, [selectedScope, settings]);
|
||||
|
||||
// Search input buffer
|
||||
const searchBuffer = useSearchBuffer({
|
||||
initialText: '',
|
||||
onChange: setSearchQuery,
|
||||
});
|
||||
|
||||
// Generate items for BaseSettingsDialog
|
||||
const settingKeys = searchQuery ? filteredKeys : getDialogSettingKeys();
|
||||
const items: SettingsDialogItem[] = useMemo(() => {
|
||||
const scopeSettings = settings.forScope(selectedScope).settings;
|
||||
const mergedSettings = settings.merged;
|
||||
@@ -164,10 +261,6 @@ export function SettingsDialog({
|
||||
});
|
||||
}, [settingKeys, selectedScope, settings, modifiedSettings, pendingSettings]);
|
||||
|
||||
const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({
|
||||
items,
|
||||
});
|
||||
|
||||
// Scope selection handler
|
||||
const handleScopeChange = useCallback((scope: LoadableSettingScope) => {
|
||||
setSelectedScope(scope);
|
||||
@@ -594,12 +687,12 @@ export function SettingsDialog({
|
||||
borderColor={showRestartPrompt ? theme.status.warning : undefined}
|
||||
searchEnabled={showSearch}
|
||||
searchBuffer={searchBuffer}
|
||||
items={filteredItems}
|
||||
items={items}
|
||||
showScopeSelector={showScopeSelection}
|
||||
selectedScope={selectedScope}
|
||||
onScopeChange={handleScopeChange}
|
||||
maxItemsToShow={effectiveMaxItemsToShow}
|
||||
maxLabelWidth={maxLabelWidth}
|
||||
maxLabelWidth={maxLabelOrDescriptionWidth}
|
||||
onItemToggle={handleItemToggle}
|
||||
onEditCommit={handleEditCommit}
|
||||
onItemClear={handleItemClear}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -23,11 +23,13 @@ import {
|
||||
import { computeSessionStats } from '../utils/computeStats.js';
|
||||
import {
|
||||
type RetrieveUserQuotaResponse,
|
||||
VALID_GEMINI_MODELS,
|
||||
isActiveModel,
|
||||
getDisplayString,
|
||||
isAutoModel,
|
||||
AuthType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import type { QuotaStats } from '../types.js';
|
||||
import { QuotaStatsInfo } from './QuotaStatsInfo.js';
|
||||
|
||||
@@ -82,9 +84,13 @@ const Section: React.FC<SectionProps> = ({ title, children }) => (
|
||||
const buildModelRows = (
|
||||
models: Record<string, ModelMetrics>,
|
||||
quotas?: RetrieveUserQuotaResponse,
|
||||
useGemini3_1 = false,
|
||||
useCustomToolModel = false,
|
||||
) => {
|
||||
const getBaseModelName = (name: string) => name.replace('-001', '');
|
||||
const usedModelNames = new Set(Object.keys(models).map(getBaseModelName));
|
||||
const usedModelNames = new Set(
|
||||
Object.keys(models).map(getBaseModelName).map(getDisplayString),
|
||||
);
|
||||
|
||||
// 1. Models with active usage
|
||||
const activeRows = Object.entries(models).map(([name, metrics]) => {
|
||||
@@ -93,7 +99,7 @@ const buildModelRows = (
|
||||
const inputTokens = metrics.tokens.input;
|
||||
return {
|
||||
key: name,
|
||||
modelName,
|
||||
modelName: getDisplayString(modelName),
|
||||
requests: metrics.api.totalRequests,
|
||||
cachedTokens: cachedTokens.toLocaleString(),
|
||||
inputTokens: inputTokens.toLocaleString(),
|
||||
@@ -109,12 +115,12 @@ const buildModelRows = (
|
||||
?.filter(
|
||||
(b) =>
|
||||
b.modelId &&
|
||||
VALID_GEMINI_MODELS.has(b.modelId) &&
|
||||
!usedModelNames.has(b.modelId),
|
||||
isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) &&
|
||||
!usedModelNames.has(getDisplayString(b.modelId)),
|
||||
)
|
||||
.map((bucket) => ({
|
||||
key: bucket.modelId!,
|
||||
modelName: bucket.modelId!,
|
||||
modelName: getDisplayString(bucket.modelId!),
|
||||
requests: '-',
|
||||
cachedTokens: '-',
|
||||
inputTokens: '-',
|
||||
@@ -135,6 +141,8 @@ const ModelUsageTable: React.FC<{
|
||||
pooledRemaining?: number;
|
||||
pooledLimit?: number;
|
||||
pooledResetTime?: string;
|
||||
useGemini3_1?: boolean;
|
||||
useCustomToolModel?: boolean;
|
||||
}> = ({
|
||||
models,
|
||||
quotas,
|
||||
@@ -144,8 +152,10 @@ const ModelUsageTable: React.FC<{
|
||||
pooledRemaining,
|
||||
pooledLimit,
|
||||
pooledResetTime,
|
||||
useGemini3_1,
|
||||
useCustomToolModel,
|
||||
}) => {
|
||||
const rows = buildModelRows(models, quotas);
|
||||
const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
@@ -403,7 +413,11 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
const { models, tools, files } = metrics;
|
||||
const computed = computeSessionStats(metrics);
|
||||
const settings = useSettings();
|
||||
|
||||
const config = useConfig();
|
||||
const useGemini3_1 = config.getGemini31LaunchedSync?.() ?? false;
|
||||
const useCustomToolModel =
|
||||
useGemini3_1 &&
|
||||
config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI;
|
||||
const pooledRemaining = quotaStats?.remaining;
|
||||
const pooledLimit = quotaStats?.limit;
|
||||
const pooledResetTime = quotaStats?.resetTime;
|
||||
@@ -544,6 +558,8 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
pooledRemaining={pooledRemaining}
|
||||
pooledLimit={pooledLimit}
|
||||
pooledResetTime={pooledResetTime}
|
||||
useGemini3_1={useGemini3_1}
|
||||
useCustomToolModel={useCustomToolModel}
|
||||
/>
|
||||
{renderFooter()}
|
||||
</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;
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('ToolConfirmationQueue', () => {
|
||||
readFile: vi.fn().mockResolvedValue('Plan content'),
|
||||
}),
|
||||
storage: {
|
||||
getProjectTempPlansDir: () => '/mock/temp/plans',
|
||||
getPlansDir: () => '/mock/temp/plans',
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
|
||||
-2
@@ -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 │
|
||||
│ │
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot in idle state 1`] = `
|
||||
"
|
||||
ShortcutsHint
|
||||
" ShortcutsHint
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
ApprovalModeIndicator StatusDisplay
|
||||
InputPrompt: Type your message or @path/to/file
|
||||
@@ -11,22 +10,19 @@ Footer
|
||||
`;
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot in minimal UI mode 1`] = `
|
||||
"
|
||||
ShortcutsHint
|
||||
" ShortcutsHint
|
||||
InputPrompt: Type your message or @path/to/file
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot in minimal UI mode while loading 1`] = `
|
||||
"
|
||||
LoadingIndicator
|
||||
" LoadingIndicator
|
||||
InputPrompt: Type your message or @path/to/file
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot in narrow view 1`] = `
|
||||
"
|
||||
|
||||
ShortcutsHint
|
||||
────────────────────────────────────────
|
||||
ApprovalModeIndicator
|
||||
@@ -39,8 +35,7 @@ Footer
|
||||
`;
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot while streaming 1`] = `
|
||||
"
|
||||
LoadingIndicator: Thinking ShortcutsHint
|
||||
" LoadingIndicator: Thinking ShortcutsHint
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
ApprovalModeIndicator
|
||||
InputPrompt: Type your message or @path/to/file
|
||||
|
||||
@@ -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
|
||||
"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`PolicyUpdateDialog > renders correctly and matches snapshot 1`] = `
|
||||
" ╭────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ New or changed workspace policies detected │
|
||||
│ Location: /test/workspace/.gemini/policies │
|
||||
│ Do you want to accept and load these policies? │
|
||||
│ │
|
||||
│ ● 1. Accept and Load │
|
||||
│ 2. Ignore (Use Default Policies) │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -22,6 +22,9 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ 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. │
|
||||
│ │
|
||||
@@ -31,9 +34,6 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ 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 │
|
||||
@@ -69,6 +69,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ 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. │
|
||||
│ │
|
||||
@@ -78,9 +81,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ 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 │
|
||||
@@ -116,6 +116,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ 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. │
|
||||
│ │
|
||||
@@ -125,9 +128,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ 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 │
|
||||
@@ -163,6 +163,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ 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. │
|
||||
│ │
|
||||
@@ -172,9 +175,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ 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 │
|
||||
@@ -210,6 +210,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ 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. │
|
||||
│ │
|
||||
@@ -219,9 +222,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ 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 │
|
||||
@@ -257,6 +257,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ 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. │
|
||||
│ │
|
||||
@@ -266,9 +269,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ 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 │
|
||||
@@ -304,6 +304,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ 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. │
|
||||
│ │
|
||||
@@ -313,9 +316,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ 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 │
|
||||
@@ -351,6 +351,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ 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. │
|
||||
│ │
|
||||
@@ -360,9 +363,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ 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 │
|
||||
@@ -398,6 +398,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. … │
|
||||
│ │
|
||||
│ 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. │
|
||||
│ │
|
||||
@@ -407,9 +410,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ 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
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { getDisplayString } from '@google/gemini-cli-core';
|
||||
|
||||
interface ModelMessageProps {
|
||||
model: string;
|
||||
@@ -15,7 +16,7 @@ interface ModelMessageProps {
|
||||
export const ModelMessage: React.FC<ModelMessageProps> = ({ model }) => (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.ui.comment} italic>
|
||||
Responding with {model}
|
||||
Responding with {getDisplayString(model)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
|
||||
import type {
|
||||
SerializableConfirmationDetails,
|
||||
ToolCallConfirmationDetails,
|
||||
Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
@@ -87,6 +88,122 @@ describe('ToolConfirmationMessage', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display WarningMessage for deceptive URLs in info type', async () => {
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'info',
|
||||
title: 'Confirm Web Fetch',
|
||||
prompt: 'https://täst.com',
|
||||
urls: ['https://täst.com'],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Deceptive URL(s) detected');
|
||||
expect(output).toContain('Original: https://täst.com');
|
||||
expect(output).toContain(
|
||||
'Actual Host (Punycode): https://xn--tst-qla.com/',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display WarningMessage for deceptive URLs in exec type commands', async () => {
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'exec',
|
||||
title: 'Confirm Execution',
|
||||
command: 'curl https://еxample.com',
|
||||
rootCommand: 'curl',
|
||||
rootCommands: ['curl'],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Deceptive URL(s) detected');
|
||||
expect(output).toContain('Original: https://еxample.com/');
|
||||
expect(output).toContain(
|
||||
'Actual Host (Punycode): https://xn--xample-2of.com/',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should exclude shell delimiters from extracted URLs in exec type commands', async () => {
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'exec',
|
||||
title: 'Confirm Execution',
|
||||
command: 'curl https://еxample.com;ls',
|
||||
rootCommand: 'curl',
|
||||
rootCommands: ['curl'],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Deceptive URL(s) detected');
|
||||
// It should extract "https://еxample.com" and NOT "https://еxample.com;ls"
|
||||
expect(output).toContain('Original: https://еxample.com/');
|
||||
// The command itself still contains 'ls', so we check specifically that 'ls' is not part of the URL line.
|
||||
expect(output).not.toContain('Original: https://еxample.com/;ls');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should aggregate multiple deceptive URLs into a single WarningMessage', async () => {
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'info',
|
||||
title: 'Confirm Web Fetch',
|
||||
prompt: 'Fetch both',
|
||||
urls: ['https://еxample.com', 'https://täst.com'],
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Deceptive URL(s) detected');
|
||||
expect(output).toContain('Original: https://еxample.com/');
|
||||
expect(output).toContain('Original: https://täst.com/');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display multiple commands for exec type when provided', async () => {
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'exec',
|
||||
@@ -372,4 +489,35 @@ describe('ToolConfirmationMessage', () => {
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('should strip BiDi characters from MCP tool and server names', async () => {
|
||||
const confirmationDetails: ToolCallConfirmationDetails = {
|
||||
type: 'mcp',
|
||||
title: 'Confirm MCP Tool',
|
||||
serverName: 'test\u202Eserver',
|
||||
toolName: 'test\u202Dtool',
|
||||
toolDisplayName: 'Test Tool',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
// BiDi characters \u202E and \u202D should be stripped
|
||||
expect(output).toContain('MCP Server: testserver');
|
||||
expect(output).toContain('Tool: testtool');
|
||||
expect(output).toContain('Allow execution of MCP tool "testtool"');
|
||||
expect(output).toContain('from server "testserver"?');
|
||||
expect(output).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,10 @@ import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
|
||||
import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
|
||||
import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js';
|
||||
import { sanitizeForDisplay } from '../../utils/textUtils.js';
|
||||
import {
|
||||
sanitizeForDisplay,
|
||||
stripUnsafeCharacters,
|
||||
} from '../../utils/textUtils.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
@@ -34,6 +37,12 @@ import {
|
||||
} from '../../textConstants.js';
|
||||
import { AskUserDialog } from '../AskUserDialog.js';
|
||||
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
|
||||
import { WarningMessage } from './WarningMessage.js';
|
||||
import {
|
||||
getDeceptiveUrlDetails,
|
||||
toUnicodeUrl,
|
||||
type DeceptiveUrlDetails,
|
||||
} from '../../utils/urlSecurityUtils.js';
|
||||
|
||||
export interface ToolConfirmationMessageProps {
|
||||
callId: string;
|
||||
@@ -99,6 +108,37 @@ export const ToolConfirmationMessage: React.FC<
|
||||
[handleConfirm],
|
||||
);
|
||||
|
||||
const deceptiveUrlWarnings = useMemo(() => {
|
||||
const urls: string[] = [];
|
||||
if (confirmationDetails.type === 'info' && confirmationDetails.urls) {
|
||||
urls.push(...confirmationDetails.urls);
|
||||
} else if (confirmationDetails.type === 'exec') {
|
||||
const commands =
|
||||
confirmationDetails.commands && confirmationDetails.commands.length > 0
|
||||
? confirmationDetails.commands
|
||||
: [confirmationDetails.command];
|
||||
for (const cmd of commands) {
|
||||
const matches = cmd.match(/https?:\/\/[^\s"'`<>;&|()]+/g);
|
||||
if (matches) urls.push(...matches);
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueUrls = Array.from(new Set(urls));
|
||||
return uniqueUrls
|
||||
.map(getDeceptiveUrlDetails)
|
||||
.filter((d): d is DeceptiveUrlDetails => d !== null);
|
||||
}, [confirmationDetails]);
|
||||
|
||||
const deceptiveUrlWarningText = useMemo(() => {
|
||||
if (deceptiveUrlWarnings.length === 0) return null;
|
||||
return `**Warning:** Deceptive URL(s) detected:\n\n${deceptiveUrlWarnings
|
||||
.map(
|
||||
(w) =>
|
||||
` **Original:** ${w.originalUrl}\n **Actual Host (Punycode):** ${w.punycodeUrl}`,
|
||||
)
|
||||
.join('\n\n')}`;
|
||||
}, [deceptiveUrlWarnings]);
|
||||
|
||||
const getOptions = useCallback(() => {
|
||||
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [];
|
||||
|
||||
@@ -259,11 +299,21 @@ export const ToolConfirmationMessage: React.FC<
|
||||
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
|
||||
}, [availableTerminalHeight, getOptions, handlesOwnUI]);
|
||||
|
||||
const { question, bodyContent, options } = useMemo(() => {
|
||||
const { question, bodyContent, options, securityWarnings } = useMemo<{
|
||||
question: string;
|
||||
bodyContent: React.ReactNode;
|
||||
options: Array<RadioSelectItem<ToolConfirmationOutcome>>;
|
||||
securityWarnings: React.ReactNode;
|
||||
}>(() => {
|
||||
let bodyContent: React.ReactNode | null = null;
|
||||
let securityWarnings: React.ReactNode | null = null;
|
||||
let question = '';
|
||||
const options = getOptions();
|
||||
|
||||
if (deceptiveUrlWarningText) {
|
||||
securityWarnings = <WarningMessage text={deceptiveUrlWarningText} />;
|
||||
}
|
||||
|
||||
if (confirmationDetails.type === 'ask_user') {
|
||||
bodyContent = (
|
||||
<AskUserDialog
|
||||
@@ -278,7 +328,12 @@ export const ToolConfirmationMessage: React.FC<
|
||||
availableHeight={availableBodyContentHeight()}
|
||||
/>
|
||||
);
|
||||
return { question: '', bodyContent, options: [] };
|
||||
return {
|
||||
question: '',
|
||||
bodyContent,
|
||||
options: [],
|
||||
securityWarnings: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (confirmationDetails.type === 'exit_plan_mode') {
|
||||
@@ -304,7 +359,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
availableHeight={availableBodyContentHeight()}
|
||||
/>
|
||||
);
|
||||
return { question: '', bodyContent, options: [] };
|
||||
return { question: '', bodyContent, options: [], securityWarnings: null };
|
||||
}
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
@@ -324,15 +379,15 @@ export const ToolConfirmationMessage: React.FC<
|
||||
} else if (confirmationDetails.type === 'mcp') {
|
||||
// mcp tool confirmation
|
||||
const mcpProps = confirmationDetails;
|
||||
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
|
||||
question = `Allow execution of MCP tool "${sanitizeForDisplay(mcpProps.toolName)}" from server "${sanitizeForDisplay(mcpProps.serverName)}"?`;
|
||||
}
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
if (!confirmationDetails.isModifying) {
|
||||
bodyContent = (
|
||||
<DiffRenderer
|
||||
diffContent={confirmationDetails.fileDiff}
|
||||
filename={confirmationDetails.fileName}
|
||||
diffContent={stripUnsafeCharacters(confirmationDetails.fileDiff)}
|
||||
filename={sanitizeForDisplay(confirmationDetails.fileName)}
|
||||
availableTerminalHeight={availableBodyContentHeight()}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
@@ -433,10 +488,10 @@ export const ToolConfirmationMessage: React.FC<
|
||||
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.primary}>URLs to fetch:</Text>
|
||||
{infoProps.urls.map((url) => (
|
||||
<Text key={url}>
|
||||
{infoProps.urls.map((urlString) => (
|
||||
<Text key={urlString}>
|
||||
{' '}
|
||||
- <RenderInline text={url} />
|
||||
- <RenderInline text={toUnicodeUrl(urlString)} />
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
@@ -449,19 +504,24 @@ export const ToolConfirmationMessage: React.FC<
|
||||
|
||||
bodyContent = (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.link}>MCP Server: {mcpProps.serverName}</Text>
|
||||
<Text color={theme.text.link}>Tool: {mcpProps.toolName}</Text>
|
||||
<Text color={theme.text.link}>
|
||||
MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
|
||||
</Text>
|
||||
<Text color={theme.text.link}>
|
||||
Tool: {sanitizeForDisplay(mcpProps.toolName)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return { question, bodyContent, options };
|
||||
return { question, bodyContent, options, securityWarnings };
|
||||
}, [
|
||||
confirmationDetails,
|
||||
getOptions,
|
||||
availableBodyContentHeight,
|
||||
terminalWidth,
|
||||
handleConfirm,
|
||||
deceptiveUrlWarningText,
|
||||
]);
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
@@ -505,6 +565,12 @@ export const ToolConfirmationMessage: React.FC<
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
|
||||
{securityWarnings && (
|
||||
<Box flexShrink={0} marginBottom={1}>
|
||||
{securityWarnings}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginBottom={1} flexShrink={0}>
|
||||
<Text color={theme.text.primary}>{question}</Text>
|
||||
</Box>
|
||||
|
||||
@@ -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();
|
||||
|
||||
+130
-3
@@ -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 │
|
||||
│ │
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
+12
@@ -35,6 +35,18 @@ Do you want to proceed?
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToolConfirmationMessage > should strip BiDi characters from MCP tool and server names 1`] = `
|
||||
"MCP Server: testserver
|
||||
Tool: testtool
|
||||
Allow execution of MCP tool "testtool" from server "testserver"?
|
||||
|
||||
● 1. Allow once
|
||||
2. Allow tool for this session
|
||||
3. Allow all server tools for this session
|
||||
4. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations' > should NOT show "allow always" when folder is untrusted 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
|
||||
+6
-6
@@ -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 │ █
|
||||
╰──────────────────────────────────────────────────────────────────────────╯ █
|
||||
█
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
+3
-3
@@ -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
-2
@@ -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 │
|
||||
|
||||
@@ -144,30 +144,28 @@ export function BaseSettingsDialog({
|
||||
useEffect(() => {
|
||||
const prevItems = prevItemsRef.current;
|
||||
if (prevItems !== items) {
|
||||
if (items.length === 0) {
|
||||
const prevActiveItem = prevItems[activeIndex];
|
||||
if (prevActiveItem) {
|
||||
const newIndex = items.findIndex((i) => i.key === prevActiveItem.key);
|
||||
if (newIndex !== -1) {
|
||||
// Item still exists in the filtered list, keep focus on it
|
||||
setActiveIndex(newIndex);
|
||||
// Adjust scroll offset to ensure the item is visible
|
||||
let newScroll = scrollOffset;
|
||||
if (newIndex < scrollOffset) newScroll = newIndex;
|
||||
else if (newIndex >= scrollOffset + maxItemsToShow)
|
||||
newScroll = newIndex - maxItemsToShow + 1;
|
||||
|
||||
const maxScroll = Math.max(0, items.length - maxItemsToShow);
|
||||
setScrollOffset(Math.min(newScroll, maxScroll));
|
||||
} else {
|
||||
// Item was filtered out, reset to the top
|
||||
setActiveIndex(0);
|
||||
setScrollOffset(0);
|
||||
}
|
||||
} else {
|
||||
setActiveIndex(0);
|
||||
setScrollOffset(0);
|
||||
} else {
|
||||
const prevActiveItem = prevItems[activeIndex];
|
||||
if (prevActiveItem) {
|
||||
const newIndex = items.findIndex((i) => i.key === prevActiveItem.key);
|
||||
if (newIndex !== -1) {
|
||||
// Item still exists in the filtered list, keep focus on it
|
||||
setActiveIndex(newIndex);
|
||||
// Adjust scroll offset to ensure the item is visible
|
||||
let newScroll = scrollOffset;
|
||||
if (newIndex < scrollOffset) newScroll = newIndex;
|
||||
else if (newIndex >= scrollOffset + maxItemsToShow)
|
||||
newScroll = newIndex - maxItemsToShow + 1;
|
||||
|
||||
const maxScroll = Math.max(0, items.length - maxItemsToShow);
|
||||
setScrollOffset(Math.min(newScroll, maxScroll));
|
||||
} else {
|
||||
// Item was filtered out, reset to the top
|
||||
setActiveIndex(0);
|
||||
setScrollOffset(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
prevItemsRef.current = items;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,27 @@ describe('<Scrollable />', () => {
|
||||
throw new Error('capturedEntry is undefined');
|
||||
}
|
||||
|
||||
// Initial state (starts at bottom because of auto-scroll logic)
|
||||
// Initial state (starts at top by default)
|
||||
expect(capturedEntry.getScrollState().scrollTop).toBe(0);
|
||||
|
||||
// Initial state with scrollToBottom={true}
|
||||
unmount();
|
||||
const { waitUntilReady: waitUntilReady2, unmount: unmount2 } =
|
||||
renderWithProviders(
|
||||
<Scrollable hasFocus={true} height={5} scrollToBottom={true}>
|
||||
<Text>Line 1</Text>
|
||||
<Text>Line 2</Text>
|
||||
<Text>Line 3</Text>
|
||||
<Text>Line 4</Text>
|
||||
<Text>Line 5</Text>
|
||||
<Text>Line 6</Text>
|
||||
<Text>Line 7</Text>
|
||||
<Text>Line 8</Text>
|
||||
<Text>Line 9</Text>
|
||||
<Text>Line 10</Text>
|
||||
</Scrollable>,
|
||||
);
|
||||
await waitUntilReady2();
|
||||
expect(capturedEntry.getScrollState().scrollTop).toBe(5);
|
||||
|
||||
// Call scrollBy multiple times (upwards) in the same tick
|
||||
@@ -116,14 +136,14 @@ describe('<Scrollable />', () => {
|
||||
capturedEntry!.scrollBy(-1);
|
||||
capturedEntry!.scrollBy(-1);
|
||||
});
|
||||
// Should have moved up by 2
|
||||
// Should have moved up by 2 (5 -> 3)
|
||||
expect(capturedEntry.getScrollState().scrollTop).toBe(3);
|
||||
|
||||
await act(async () => {
|
||||
capturedEntry!.scrollBy(-2);
|
||||
});
|
||||
expect(capturedEntry.getScrollState().scrollTop).toBe(1);
|
||||
unmount();
|
||||
unmount2();
|
||||
});
|
||||
|
||||
describe('keypress handling', () => {
|
||||
|
||||
@@ -54,8 +54,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
const childrenCountRef = useRef(0);
|
||||
|
||||
// This effect needs to run on every render to correctly measure the container
|
||||
// and scroll to the bottom if new children are added. The if conditions
|
||||
// prevent infinite loops.
|
||||
// and scroll to the bottom if new children are added.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useLayoutEffect(() => {
|
||||
if (!ref.current) {
|
||||
@@ -64,7 +63,8 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
const innerHeight = Math.round(getInnerHeight(ref.current));
|
||||
const scrollHeight = Math.round(getScrollHeight(ref.current));
|
||||
|
||||
const isAtBottom = scrollTop >= size.scrollHeight - size.innerHeight - 1;
|
||||
const isAtBottom =
|
||||
scrollHeight > innerHeight && scrollTop >= scrollHeight - innerHeight - 1;
|
||||
|
||||
if (
|
||||
size.innerHeight !== innerHeight ||
|
||||
|
||||
@@ -8,11 +8,50 @@ import React from 'react';
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SearchableList, type SearchableListProps } from './SearchableList.js';
|
||||
import {
|
||||
SearchableList,
|
||||
type SearchableListProps,
|
||||
type SearchListState,
|
||||
type GenericListItem,
|
||||
} from './SearchableList.js';
|
||||
import { KeypressProvider } from '../../contexts/KeypressContext.js';
|
||||
import { type GenericListItem } from '../../hooks/useFuzzyList.js';
|
||||
import { useTextBuffer } from './text-buffer.js';
|
||||
|
||||
const useMockSearch = (props: {
|
||||
items: GenericListItem[];
|
||||
initialQuery?: string;
|
||||
onSearch?: (query: string) => void;
|
||||
}): SearchListState<GenericListItem> => {
|
||||
const { onSearch, items, initialQuery = '' } = props;
|
||||
const [text, setText] = React.useState(initialQuery);
|
||||
const filteredItems = React.useMemo(
|
||||
() =>
|
||||
items.filter((item: GenericListItem) =>
|
||||
item.label.toLowerCase().includes(text.toLowerCase()),
|
||||
),
|
||||
[items, text],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
onSearch?.(text);
|
||||
}, [text, onSearch]);
|
||||
|
||||
const searchBuffer = useTextBuffer({
|
||||
initialText: text,
|
||||
onChange: setText,
|
||||
viewport: { width: 100, height: 1 },
|
||||
singleLine: true,
|
||||
});
|
||||
|
||||
return {
|
||||
filteredItems,
|
||||
searchBuffer,
|
||||
searchQuery: text,
|
||||
setSearchQuery: setText,
|
||||
maxLabelWidth: 10,
|
||||
};
|
||||
};
|
||||
|
||||
// Mock UI State
|
||||
vi.mock('../../contexts/UIStateContext.js', () => ({
|
||||
useUIState: () => ({
|
||||
mainAreaWidth: 100,
|
||||
@@ -55,6 +94,7 @@ describe('SearchableList', () => {
|
||||
items: mockItems,
|
||||
onSelect: mockOnSelect,
|
||||
onClose: mockOnClose,
|
||||
useSearch: useMockSearch,
|
||||
...props,
|
||||
};
|
||||
|
||||
@@ -70,22 +110,59 @@ describe('SearchableList', () => {
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
|
||||
// Check for title
|
||||
expect(frame).toContain('Test List');
|
||||
|
||||
// Check for items
|
||||
expect(frame).toContain('Item One');
|
||||
expect(frame).toContain('Item Two');
|
||||
expect(frame).toContain('Item Three');
|
||||
|
||||
// Check for descriptions
|
||||
expect(frame).toContain('Description for item one');
|
||||
});
|
||||
|
||||
it('should reset selection to top when items change if resetSelectionOnItemsChange is true', async () => {
|
||||
const { lastFrame, stdin, waitUntilReady } = renderList({
|
||||
resetSelectionOnItemsChange: true,
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await React.act(async () => {
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('● Item Two');
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
|
||||
await React.act(async () => {
|
||||
stdin.write('One');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Item One');
|
||||
expect(frame).not.toContain('Item Two');
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
|
||||
await React.act(async () => {
|
||||
// Backspace "One" (3 chars)
|
||||
stdin.write('\u007F\u007F\u007F');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Item Two');
|
||||
expect(frame).toContain('● Item One');
|
||||
expect(frame).not.toContain('● Item Two');
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should filter items based on search query', async () => {
|
||||
const { lastFrame, stdin } = renderList();
|
||||
|
||||
// Type "Two" into search
|
||||
await React.act(async () => {
|
||||
stdin.write('Two');
|
||||
});
|
||||
@@ -101,7 +178,6 @@ describe('SearchableList', () => {
|
||||
it('should show "No items found." when no items match', async () => {
|
||||
const { lastFrame, stdin } = renderList();
|
||||
|
||||
// Type something that won't match
|
||||
await React.act(async () => {
|
||||
stdin.write('xyz123');
|
||||
});
|
||||
@@ -115,7 +191,6 @@ describe('SearchableList', () => {
|
||||
it('should handle selection with Enter', async () => {
|
||||
const { stdin } = renderList();
|
||||
|
||||
// Select first item (default active)
|
||||
await React.act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
});
|
||||
@@ -128,12 +203,10 @@ describe('SearchableList', () => {
|
||||
it('should handle navigation and selection', async () => {
|
||||
const { stdin } = renderList();
|
||||
|
||||
// Navigate down to second item
|
||||
await React.act(async () => {
|
||||
stdin.write('\u001B[B'); // Down Arrow
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
});
|
||||
|
||||
// Select second item
|
||||
await React.act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
});
|
||||
@@ -154,4 +227,10 @@ describe('SearchableList', () => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should match snapshot', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderList();
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,136 +1,206 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useSelectionList } from '../../hooks/useSelectionList.js';
|
||||
import { TextInput } from './TextInput.js';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import type { TextBuffer } from './text-buffer.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
import {
|
||||
useFuzzyList,
|
||||
type GenericListItem,
|
||||
} from '../../hooks/useFuzzyList.js';
|
||||
|
||||
export interface SearchableListProps<T extends GenericListItem> {
|
||||
/** List title */
|
||||
title?: string;
|
||||
/** Available items */
|
||||
items: T[];
|
||||
/** Callback when an item is selected */
|
||||
onSelect: (item: T) => void;
|
||||
/** Callback when the list is closed (e.g. via Esc) */
|
||||
onClose?: () => void;
|
||||
/** Initial search query */
|
||||
initialSearchQuery?: string;
|
||||
/** Placeholder for search input */
|
||||
searchPlaceholder?: string;
|
||||
/** Max items to show at once */
|
||||
maxItemsToShow?: number;
|
||||
/**
|
||||
* Generic interface for items in a searchable list.
|
||||
*/
|
||||
export interface GenericListItem {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic searchable list component.
|
||||
* State returned by the search hook.
|
||||
*/
|
||||
export interface SearchListState<T extends GenericListItem> {
|
||||
filteredItems: T[];
|
||||
searchBuffer: TextBuffer | undefined;
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
maxLabelWidth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the SearchableList component.
|
||||
*/
|
||||
export interface SearchableListProps<T extends GenericListItem> {
|
||||
title?: string;
|
||||
items: T[];
|
||||
onSelect: (item: T) => void;
|
||||
onClose: () => void;
|
||||
searchPlaceholder?: string;
|
||||
/** Custom item renderer */
|
||||
renderItem?: (
|
||||
item: T,
|
||||
isActive: boolean,
|
||||
labelWidth: number,
|
||||
) => React.ReactNode;
|
||||
/** Optional header content */
|
||||
header?: React.ReactNode;
|
||||
/** Optional footer content */
|
||||
footer?: (info: {
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
totalVisible: number;
|
||||
}) => React.ReactNode;
|
||||
maxItemsToShow?: number;
|
||||
/** Hook to handle search logic */
|
||||
useSearch: (props: {
|
||||
items: T[];
|
||||
onSearch?: (query: string) => void;
|
||||
}) => SearchListState<T>;
|
||||
onSearch?: (query: string) => void;
|
||||
/** Whether to reset selection to the top when items change (e.g. after search) */
|
||||
resetSelectionOnItemsChange?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic searchable list component with keyboard navigation.
|
||||
*/
|
||||
export function SearchableList<T extends GenericListItem>({
|
||||
title,
|
||||
items,
|
||||
onSelect,
|
||||
onClose,
|
||||
initialSearchQuery = '',
|
||||
searchPlaceholder = 'Search...',
|
||||
renderItem,
|
||||
header,
|
||||
footer,
|
||||
maxItemsToShow = 10,
|
||||
useSearch,
|
||||
onSearch,
|
||||
resetSelectionOnItemsChange = false,
|
||||
}: SearchableListProps<T>): React.JSX.Element {
|
||||
const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({
|
||||
const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({
|
||||
items,
|
||||
initialQuery: initialSearchQuery,
|
||||
onSearch,
|
||||
});
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
|
||||
// Reset selection when filtered items change
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
setScrollOffset(0);
|
||||
}, [filteredItems]);
|
||||
|
||||
// Calculate visible items
|
||||
const visibleItems = filteredItems.slice(
|
||||
scrollOffset,
|
||||
scrollOffset + maxItemsToShow,
|
||||
const selectionItems = useMemo(
|
||||
() =>
|
||||
filteredItems.map((item) => ({
|
||||
key: item.key,
|
||||
value: item,
|
||||
})),
|
||||
[filteredItems],
|
||||
);
|
||||
const showScrollUp = scrollOffset > 0;
|
||||
const showScrollDown = scrollOffset + maxItemsToShow < filteredItems.length;
|
||||
|
||||
const handleSelectValue = useCallback(
|
||||
(item: T) => {
|
||||
onSelect(item);
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
const { activeIndex, setActiveIndex } = useSelectionList({
|
||||
items: selectionItems,
|
||||
onSelect: handleSelectValue,
|
||||
isFocused: true,
|
||||
showNumbers: false,
|
||||
wrapAround: true,
|
||||
priority: true,
|
||||
});
|
||||
|
||||
const [scrollOffsetState, setScrollOffsetState] = React.useState(0);
|
||||
|
||||
// Compute effective scroll offset during render to avoid visual flicker
|
||||
let scrollOffset = scrollOffsetState;
|
||||
|
||||
if (activeIndex < scrollOffset) {
|
||||
scrollOffset = activeIndex;
|
||||
} else if (activeIndex >= scrollOffset + maxItemsToShow) {
|
||||
scrollOffset = activeIndex - maxItemsToShow + 1;
|
||||
}
|
||||
|
||||
const maxScroll = Math.max(0, filteredItems.length - maxItemsToShow);
|
||||
if (scrollOffset > maxScroll) {
|
||||
scrollOffset = maxScroll;
|
||||
}
|
||||
|
||||
// Update state to match derived value if it changed
|
||||
if (scrollOffsetState !== scrollOffset) {
|
||||
setScrollOffsetState(scrollOffset);
|
||||
}
|
||||
|
||||
// Reset selection to top when items change if requested
|
||||
const prevItemsRef = React.useRef(filteredItems);
|
||||
React.useLayoutEffect(() => {
|
||||
if (resetSelectionOnItemsChange && filteredItems !== prevItemsRef.current) {
|
||||
setActiveIndex(0);
|
||||
setScrollOffsetState(0);
|
||||
}
|
||||
prevItemsRef.current = filteredItems;
|
||||
}, [filteredItems, setActiveIndex, resetSelectionOnItemsChange]);
|
||||
|
||||
// Handle global Escape key to close the list
|
||||
useKeypress(
|
||||
(key: Key) => {
|
||||
// Navigation
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
|
||||
const newIndex =
|
||||
activeIndex > 0 ? activeIndex - 1 : filteredItems.length - 1;
|
||||
setActiveIndex(newIndex);
|
||||
if (newIndex === filteredItems.length - 1) {
|
||||
setScrollOffset(Math.max(0, filteredItems.length - maxItemsToShow));
|
||||
} else if (newIndex < scrollOffset) {
|
||||
setScrollOffset(newIndex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
|
||||
const newIndex =
|
||||
activeIndex < filteredItems.length - 1 ? activeIndex + 1 : 0;
|
||||
setActiveIndex(newIndex);
|
||||
if (newIndex === 0) {
|
||||
setScrollOffset(0);
|
||||
} else if (newIndex >= scrollOffset + maxItemsToShow) {
|
||||
setScrollOffset(newIndex - maxItemsToShow + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Selection
|
||||
if (keyMatchers[Command.RETURN](key)) {
|
||||
const item = filteredItems[activeIndex];
|
||||
if (item) {
|
||||
onSelect(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Close
|
||||
(key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
onClose?.();
|
||||
return;
|
||||
onClose();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const visibleItems = filteredItems.slice(
|
||||
scrollOffset,
|
||||
scrollOffset + maxItemsToShow,
|
||||
);
|
||||
|
||||
const defaultRenderItem = (
|
||||
item: T,
|
||||
isActive: boolean,
|
||||
labelWidth: number,
|
||||
) => (
|
||||
<Box flexDirection="row" alignItems="flex-start">
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text color={isActive ? theme.status.success : theme.text.secondary}>
|
||||
{isActive ? '●' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" flexGrow={1} minWidth={0}>
|
||||
<Text color={isActive ? theme.status.success : theme.text.primary}>
|
||||
{item.label.padEnd(labelWidth)}
|
||||
</Text>
|
||||
{item.description && (
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{item.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
{/* Header */}
|
||||
<Box flexDirection="column" width="100%" height="100%" paddingX={1}>
|
||||
{title && (
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>{title}</Text>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Search Input */}
|
||||
{searchBuffer && (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.focused}
|
||||
borderColor={theme.border.default}
|
||||
paddingX={1}
|
||||
marginBottom={1}
|
||||
>
|
||||
@@ -142,46 +212,46 @@ export function SearchableList<T extends GenericListItem>({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* List */}
|
||||
<Box flexDirection="column">
|
||||
{visibleItems.length === 0 ? (
|
||||
<Text color={theme.text.secondary}>No items found.</Text>
|
||||
) : (
|
||||
visibleItems.map((item, idx) => {
|
||||
const index = scrollOffset + idx;
|
||||
const isActive = index === activeIndex;
|
||||
{header && <Box marginBottom={1}>{header}</Box>}
|
||||
|
||||
return (
|
||||
<Box key={item.key} flexDirection="row">
|
||||
<Text
|
||||
color={isActive ? theme.status.success : theme.text.secondary}
|
||||
>
|
||||
{isActive ? '> ' : ' '}
|
||||
</Text>
|
||||
<Box width={maxLabelWidth + 2}>
|
||||
<Text
|
||||
color={isActive ? theme.status.success : theme.text.primary}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Box>
|
||||
{item.description && (
|
||||
<Text color={theme.text.secondary}>{item.description}</Text>
|
||||
)}
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{filteredItems.length === 0 ? (
|
||||
<Box marginX={2}>
|
||||
<Text color={theme.text.secondary}>No items found.</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{filteredItems.length > maxItemsToShow && (
|
||||
<Box marginX={1}>
|
||||
<Text color={theme.text.secondary}>▲</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{visibleItems.map((item, index) => {
|
||||
const isSelected = activeIndex === scrollOffset + index;
|
||||
return (
|
||||
<Box key={item.key} marginBottom={1} marginX={1}>
|
||||
{renderItem
|
||||
? renderItem(item, isSelected, maxLabelWidth)
|
||||
: defaultRenderItem(item, isSelected, maxLabelWidth)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{filteredItems.length > maxItemsToShow && (
|
||||
<Box marginX={1}>
|
||||
<Text color={theme.text.secondary}>▼</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Footer/Scroll Indicators */}
|
||||
{(showScrollUp || showScrollDown) && (
|
||||
<Box marginTop={1} justifyContent="center">
|
||||
<Text color={theme.text.secondary}>
|
||||
{showScrollUp ? '▲ ' : ' '}
|
||||
{filteredItems.length} items
|
||||
{showScrollDown ? ' ▼' : ' '}
|
||||
</Text>
|
||||
{footer && (
|
||||
<Box marginTop={1}>
|
||||
{footer({
|
||||
startIndex: scrollOffset,
|
||||
endIndex: scrollOffset + visibleItems.length,
|
||||
totalVisible: filteredItems.length,
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`SearchableList > should match snapshot 1`] = `
|
||||
" Test List
|
||||
|
||||
╭────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Search... │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
● Item One
|
||||
Description for item one
|
||||
|
||||
Item Two
|
||||
Description for item two
|
||||
|
||||
Item Three
|
||||
Description for item three
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`SearchableList > should reset selection to top when items change if resetSelectionOnItemsChange is true 1`] = `
|
||||
" Test List
|
||||
|
||||
╭────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Search... │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
Item One
|
||||
Description for item one
|
||||
|
||||
● Item Two
|
||||
Description for item two
|
||||
|
||||
Item Three
|
||||
Description for item three
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`SearchableList > should reset selection to top when items change if resetSelectionOnItemsChange is true 2`] = `
|
||||
" Test List
|
||||
|
||||
╭────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ One │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
● Item One
|
||||
Description for item one
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`SearchableList > should reset selection to top when items change if resetSelectionOnItemsChange is true 3`] = `
|
||||
" Test List
|
||||
|
||||
╭────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Search... │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
● Item One
|
||||
Description for item one
|
||||
|
||||
Item Two
|
||||
Description for item two
|
||||
|
||||
Item Three
|
||||
Description for item three
|
||||
"
|
||||
`;
|
||||
@@ -451,6 +451,7 @@ Return a JSON object with:
|
||||
'--limit',
|
||||
String(limit),
|
||||
]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const issues: Issue[] = JSON.parse(stdout);
|
||||
if (issues.length === 0) {
|
||||
setState((s) => ({
|
||||
|
||||
@@ -137,6 +137,7 @@ export const TriageIssues = ({
|
||||
'--limit',
|
||||
String(limit),
|
||||
]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const issues: Issue[] = JSON.parse(stdout);
|
||||
if (issues.length === 0) {
|
||||
setState((s) => ({
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ExtensionRegistryView } from './ExtensionRegistryView.js';
|
||||
import { type ExtensionManager } from '../../../config/extension-manager.js';
|
||||
import { useExtensionRegistry } from '../../hooks/useExtensionRegistry.js';
|
||||
import { useExtensionUpdates } from '../../hooks/useExtensionUpdates.js';
|
||||
import { useRegistrySearch } from '../../hooks/useRegistrySearch.js';
|
||||
import { type RegistryExtension } from '../../../config/extensionRegistryClient.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { KeypressProvider } from '../../contexts/KeypressContext.js';
|
||||
import {
|
||||
type SearchListState,
|
||||
type GenericListItem,
|
||||
} from '../shared/SearchableList.js';
|
||||
import { type TextBuffer } from '../shared/text-buffer.js';
|
||||
|
||||
// Mocks
|
||||
vi.mock('../../hooks/useExtensionRegistry.js');
|
||||
vi.mock('../../hooks/useExtensionUpdates.js');
|
||||
vi.mock('../../hooks/useRegistrySearch.js');
|
||||
vi.mock('../../../config/extension-manager.js');
|
||||
vi.mock('../../contexts/UIStateContext.js');
|
||||
vi.mock('../../contexts/ConfigContext.js');
|
||||
|
||||
const mockExtensions: RegistryExtension[] = [
|
||||
{
|
||||
id: 'ext1',
|
||||
extensionName: 'Test Extension 1',
|
||||
extensionDescription: 'Description 1',
|
||||
fullName: 'author/ext1',
|
||||
extensionVersion: '1.0.0',
|
||||
rank: 1,
|
||||
stars: 10,
|
||||
url: 'http://example.com',
|
||||
repoDescription: 'Repo Desc 1',
|
||||
avatarUrl: 'http://avatar.com',
|
||||
lastUpdated: '2023-01-01',
|
||||
hasMCP: false,
|
||||
hasContext: false,
|
||||
hasHooks: false,
|
||||
hasSkills: false,
|
||||
hasCustomCommands: false,
|
||||
isGoogleOwned: false,
|
||||
licenseKey: 'mit',
|
||||
},
|
||||
{
|
||||
id: 'ext2',
|
||||
extensionName: 'Test Extension 2',
|
||||
extensionDescription: 'Description 2',
|
||||
fullName: 'author/ext2',
|
||||
extensionVersion: '2.0.0',
|
||||
rank: 2,
|
||||
stars: 20,
|
||||
url: 'http://example.com/2',
|
||||
repoDescription: 'Repo Desc 2',
|
||||
avatarUrl: 'http://avatar.com/2',
|
||||
lastUpdated: '2023-01-02',
|
||||
hasMCP: true,
|
||||
hasContext: true,
|
||||
hasHooks: true,
|
||||
hasSkills: true,
|
||||
hasCustomCommands: true,
|
||||
isGoogleOwned: true,
|
||||
licenseKey: 'apache-2.0',
|
||||
},
|
||||
];
|
||||
|
||||
describe('ExtensionRegistryView', () => {
|
||||
let mockExtensionManager: ExtensionManager;
|
||||
let mockOnSelect: ReturnType<typeof vi.fn>;
|
||||
let mockOnClose: ReturnType<typeof vi.fn>;
|
||||
let mockSearch: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockExtensionManager = {
|
||||
getExtensions: vi.fn().mockReturnValue([]),
|
||||
} as unknown as ExtensionManager;
|
||||
|
||||
mockOnSelect = vi.fn();
|
||||
mockOnClose = vi.fn();
|
||||
mockSearch = vi.fn();
|
||||
|
||||
vi.mocked(useExtensionRegistry).mockReturnValue({
|
||||
extensions: mockExtensions,
|
||||
loading: false,
|
||||
error: null,
|
||||
search: mockSearch,
|
||||
});
|
||||
|
||||
vi.mocked(useExtensionUpdates).mockReturnValue({
|
||||
extensionsUpdateState: new Map(),
|
||||
} as unknown as ReturnType<typeof useExtensionUpdates>);
|
||||
|
||||
// Mock useRegistrySearch implementation
|
||||
vi.mocked(useRegistrySearch).mockImplementation(
|
||||
(props: { items: GenericListItem[]; onSearch?: (q: string) => void }) =>
|
||||
({
|
||||
filteredItems: props.items, // Pass through items
|
||||
searchBuffer: {
|
||||
text: '',
|
||||
cursorOffset: 0,
|
||||
viewport: { width: 10, height: 1 },
|
||||
visualCursor: [0, 0] as [number, number],
|
||||
viewportVisualLines: [{ text: '', visualRowIndex: 0 }],
|
||||
visualScrollRow: 0,
|
||||
lines: [''],
|
||||
cursor: [0, 0] as [number, number],
|
||||
selectionAnchor: undefined,
|
||||
} as unknown as TextBuffer,
|
||||
searchQuery: '',
|
||||
setSearchQuery: vi.fn(),
|
||||
maxLabelWidth: 10,
|
||||
}) as unknown as SearchListState<GenericListItem>,
|
||||
);
|
||||
|
||||
vi.mocked(useUIState).mockReturnValue({
|
||||
mainAreaWidth: 100,
|
||||
terminalHeight: 40,
|
||||
staticExtraHeight: 5,
|
||||
} as unknown as ReturnType<typeof useUIState>);
|
||||
|
||||
vi.mocked(useConfig).mockReturnValue({
|
||||
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
|
||||
} as unknown as ReturnType<typeof useConfig>);
|
||||
});
|
||||
|
||||
const renderView = () =>
|
||||
render(
|
||||
<KeypressProvider>
|
||||
<ExtensionRegistryView
|
||||
extensionManager={mockExtensionManager}
|
||||
onSelect={mockOnSelect}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
it('should render extensions', async () => {
|
||||
const { lastFrame } = renderView();
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Test Extension 1');
|
||||
expect(lastFrame()).toContain('Test Extension 2');
|
||||
});
|
||||
});
|
||||
|
||||
it('should use useRegistrySearch hook', () => {
|
||||
renderView();
|
||||
expect(useRegistrySearch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call search function when typing', async () => {
|
||||
// Mock useRegistrySearch to trigger onSearch
|
||||
vi.mocked(useRegistrySearch).mockImplementation(
|
||||
(props: {
|
||||
items: GenericListItem[];
|
||||
onSearch?: (q: string) => void;
|
||||
}): SearchListState<GenericListItem> => {
|
||||
const { onSearch } = props;
|
||||
// Simulate typing
|
||||
React.useEffect(() => {
|
||||
if (onSearch) {
|
||||
onSearch('test query');
|
||||
}
|
||||
}, [onSearch]);
|
||||
return {
|
||||
filteredItems: props.items,
|
||||
searchBuffer: {
|
||||
text: 'test query',
|
||||
cursorOffset: 10,
|
||||
viewport: { width: 10, height: 1 },
|
||||
visualCursor: [0, 10] as [number, number],
|
||||
viewportVisualLines: [{ text: 'test query', visualRowIndex: 0 }],
|
||||
visualScrollRow: 0,
|
||||
lines: ['test query'],
|
||||
cursor: [0, 10] as [number, number],
|
||||
selectionAnchor: undefined,
|
||||
} as unknown as TextBuffer,
|
||||
searchQuery: 'test query',
|
||||
setSearchQuery: vi.fn(),
|
||||
maxLabelWidth: 10,
|
||||
} as unknown as SearchListState<GenericListItem>;
|
||||
},
|
||||
);
|
||||
|
||||
renderView();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useRegistrySearch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
onSearch: mockSearch,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { RegistryExtension } from '../../../config/extensionRegistryClient.js';
|
||||
|
||||
import {
|
||||
SearchableList,
|
||||
type GenericListItem,
|
||||
} from '../shared/SearchableList.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
import { useExtensionRegistry } from '../../hooks/useExtensionRegistry.js';
|
||||
import { ExtensionUpdateState } from '../../state/extensions.js';
|
||||
import { useExtensionUpdates } from '../../hooks/useExtensionUpdates.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import type { ExtensionManager } from '../../../config/extension-manager.js';
|
||||
import { useRegistrySearch } from '../../hooks/useRegistrySearch.js';
|
||||
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
interface ExtensionRegistryViewProps {
|
||||
onSelect?: (extension: RegistryExtension) => void;
|
||||
onClose?: () => void;
|
||||
extensionManager: ExtensionManager;
|
||||
}
|
||||
|
||||
interface ExtensionItem extends GenericListItem {
|
||||
extension: RegistryExtension;
|
||||
}
|
||||
|
||||
export function ExtensionRegistryView({
|
||||
onSelect,
|
||||
onClose,
|
||||
extensionManager,
|
||||
}: ExtensionRegistryViewProps): React.JSX.Element {
|
||||
const { extensions, loading, error, search } = useExtensionRegistry();
|
||||
const config = useConfig();
|
||||
const { terminalHeight, staticExtraHeight } = useUIState();
|
||||
|
||||
const { extensionsUpdateState } = useExtensionUpdates(
|
||||
extensionManager,
|
||||
() => 0,
|
||||
config.getEnableExtensionReloading(),
|
||||
);
|
||||
|
||||
const installedExtensions = extensionManager.getExtensions();
|
||||
|
||||
const items: ExtensionItem[] = useMemo(
|
||||
() =>
|
||||
extensions.map((ext) => ({
|
||||
key: ext.id,
|
||||
label: ext.extensionName,
|
||||
description: ext.extensionDescription || ext.repoDescription,
|
||||
extension: ext,
|
||||
})),
|
||||
[extensions],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(item: ExtensionItem) => {
|
||||
onSelect?.(item.extension);
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(item: ExtensionItem, isActive: boolean, _labelWidth: number) => {
|
||||
const isInstalled = installedExtensions.some(
|
||||
(e) => e.name === item.extension.extensionName,
|
||||
);
|
||||
const updateState = extensionsUpdateState.get(
|
||||
item.extension.extensionName,
|
||||
);
|
||||
const hasUpdate = updateState === ExtensionUpdateState.UPDATE_AVAILABLE;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" width="100%" justifyContent="space-between">
|
||||
<Box flexDirection="row" flexShrink={1} minWidth={0}>
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text
|
||||
color={isActive ? theme.status.success : theme.text.secondary}
|
||||
>
|
||||
{isActive ? '● ' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexShrink={0}>
|
||||
<Text
|
||||
bold={isActive}
|
||||
color={isActive ? theme.status.success : theme.text.primary}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexShrink={0} marginX={1}>
|
||||
<Text color={theme.text.secondary}>|</Text>
|
||||
</Box>
|
||||
{isInstalled && (
|
||||
<Box marginRight={1} flexShrink={0}>
|
||||
<Text color={theme.status.success}>[Installed]</Text>
|
||||
</Box>
|
||||
)}
|
||||
{hasUpdate && (
|
||||
<Box marginRight={1} flexShrink={0}>
|
||||
<Text color={theme.status.warning}>[Update available]</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box flexShrink={1} minWidth={0}>
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{item.description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flexShrink={0} marginLeft={2} width={8} flexDirection="row">
|
||||
<Text color={theme.status.warning}>⭐</Text>
|
||||
<Text
|
||||
color={isActive ? theme.status.success : theme.text.secondary}
|
||||
>
|
||||
{' '}
|
||||
{item.extension.stars || 0}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
[installedExtensions, extensionsUpdateState],
|
||||
);
|
||||
|
||||
const header = useMemo(
|
||||
() => (
|
||||
<Box flexDirection="row" justifyContent="space-between" width="100%">
|
||||
<Box flexShrink={1}>
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
Browse and search extensions from the registry.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexShrink={0} marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{installedExtensions.length &&
|
||||
`${installedExtensions.length} installed`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
),
|
||||
[installedExtensions.length],
|
||||
);
|
||||
|
||||
const footer = useCallback(
|
||||
({
|
||||
startIndex,
|
||||
endIndex,
|
||||
totalVisible,
|
||||
}: {
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
totalVisible: number;
|
||||
}) => (
|
||||
<Text color={theme.text.secondary}>
|
||||
({startIndex + 1}-{endIndex}) / {totalVisible}
|
||||
</Text>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const maxItemsToShow = useMemo(() => {
|
||||
// SearchableList layout overhead:
|
||||
// Container paddingY: 0
|
||||
// Title (marginBottom 1): 2
|
||||
// Search buffer (border 2, marginBottom 1): 4
|
||||
// Header (marginBottom 1): 2
|
||||
// Footer (marginTop 1): 2
|
||||
// List item (marginBottom 1): 2 per item
|
||||
// Total static height = 2 + 4 + 2 + 2 = 10
|
||||
const staticHeight = 10;
|
||||
const availableTerminalHeight = terminalHeight - staticExtraHeight;
|
||||
const remainingHeight = Math.max(0, availableTerminalHeight - staticHeight);
|
||||
const itemHeight = 2; // Each item takes 2 lines (content + marginBottom 1)
|
||||
|
||||
// Ensure we show at least a few items and not more than we have
|
||||
return Math.max(4, Math.floor(remainingHeight / itemHeight));
|
||||
}, [terminalHeight, staticExtraHeight]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box padding={1}>
|
||||
<Text color={theme.text.secondary}>Loading extensions...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box padding={1} flexDirection="column">
|
||||
<Text color={theme.status.error}>Error loading extensions:</Text>
|
||||
<Text color={theme.text.secondary}>{error}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchableList<ExtensionItem>
|
||||
title="Extensions"
|
||||
items={items}
|
||||
onSelect={handleSelect}
|
||||
onClose={onClose || (() => {})}
|
||||
searchPlaceholder="Search extension gallery"
|
||||
renderItem={renderItem}
|
||||
header={header}
|
||||
footer={footer}
|
||||
maxItemsToShow={maxItemsToShow}
|
||||
useSearch={useRegistrySearch}
|
||||
onSearch={search}
|
||||
resetSelectionOnItemsChange={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { StartupWarning } from '@google/gemini-cli-core';
|
||||
|
||||
export interface AppState {
|
||||
version: string;
|
||||
startupWarnings: string[];
|
||||
startupWarnings: StartupWarning[];
|
||||
}
|
||||
|
||||
export const AppContext = createContext<AppState | null>(null);
|
||||
|
||||
@@ -758,6 +758,80 @@ describe('KeypressContext', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('Numpad support', () => {
|
||||
it.each([
|
||||
{
|
||||
sequence: '\x1bOj',
|
||||
expected: { name: '*', sequence: '*', insertable: true },
|
||||
},
|
||||
{
|
||||
sequence: '\x1bOk',
|
||||
expected: { name: '+', sequence: '+', insertable: true },
|
||||
},
|
||||
{
|
||||
sequence: '\x1bOm',
|
||||
expected: { name: '-', sequence: '-', insertable: true },
|
||||
},
|
||||
{
|
||||
sequence: '\x1bOo',
|
||||
expected: { name: '/', sequence: '/', insertable: true },
|
||||
},
|
||||
{
|
||||
sequence: '\x1bOp',
|
||||
expected: { name: '0', sequence: '0', insertable: true },
|
||||
},
|
||||
{
|
||||
sequence: '\x1bOq',
|
||||
expected: { name: '1', sequence: '1', insertable: true },
|
||||
},
|
||||
{
|
||||
sequence: '\x1bOr',
|
||||
expected: { name: '2', sequence: '2', insertable: true },
|
||||
},
|
||||
{
|
||||
sequence: '\x1bOs',
|
||||
expected: { name: '3', sequence: '3', insertable: true },
|
||||
},
|
||||
{
|
||||
sequence: '\x1bOt',
|
||||
expected: { name: '4', sequence: '4', insertable: true },
|
||||
},
|
||||
{
|
||||
sequence: '\x1bOu',
|
||||
expected: { name: '5', sequence: '5', insertable: true },
|
||||
},
|
||||
{
|
||||
sequence: '\x1bOv',
|
||||
expected: { name: '6', sequence: '6', insertable: true },
|
||||
},
|
||||
{
|
||||
sequence: '\x1bOw',
|
||||
expected: { name: '7', sequence: '7', insertable: true },
|
||||
},
|
||||
{
|
||||
sequence: '\x1bOx',
|
||||
expected: { name: '8', sequence: '8', insertable: true },
|
||||
},
|
||||
{
|
||||
sequence: '\x1bOy',
|
||||
expected: { name: '9', sequence: '9', insertable: true },
|
||||
},
|
||||
{
|
||||
sequence: '\x1bOn',
|
||||
expected: { name: '.', sequence: '.', insertable: true },
|
||||
},
|
||||
])(
|
||||
'should recognize numpad sequence "$sequence" as $expected.name',
|
||||
({ sequence, expected }) => {
|
||||
const { keyHandler } = setupKeypressTest();
|
||||
act(() => stdin.write(sequence));
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining(expected),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Double-tap and batching', () => {
|
||||
it('should emit two delete events for double-tap CSI[3~', async () => {
|
||||
const { keyHandler } = setupKeypressTest();
|
||||
|
||||
@@ -122,6 +122,25 @@ const KEY_INFO_MAP: Record<
|
||||
'[8^': { name: 'end', ctrl: true },
|
||||
};
|
||||
|
||||
// Numpad keys in Application Keypad Mode (SS3 sequences)
|
||||
const NUMPAD_MAP: Record<string, string> = {
|
||||
Oj: '*',
|
||||
Ok: '+',
|
||||
Om: '-',
|
||||
Oo: '/',
|
||||
Op: '0',
|
||||
Oq: '1',
|
||||
Or: '2',
|
||||
Os: '3',
|
||||
Ot: '4',
|
||||
Ou: '5',
|
||||
Ov: '6',
|
||||
Ow: '7',
|
||||
Ox: '8',
|
||||
Oy: '9',
|
||||
On: '.',
|
||||
};
|
||||
|
||||
const kUTF16SurrogateThreshold = 0x10000; // 2 ** 16
|
||||
function charLengthAt(str: string, i: number): number {
|
||||
if (str.length <= i) {
|
||||
@@ -538,18 +557,27 @@ function* emitKeys(
|
||||
insertable = true;
|
||||
}
|
||||
} else {
|
||||
name = 'undefined';
|
||||
if (
|
||||
(ctrl || cmd || alt) &&
|
||||
(code.endsWith('u') || code.endsWith('~'))
|
||||
) {
|
||||
// CSI-u or tilde-coded functional keys: ESC [ <code> ; <mods> (u|~)
|
||||
const codeNumber = parseInt(code.slice(1, -1), 10);
|
||||
const numpadChar = NUMPAD_MAP[code];
|
||||
if (numpadChar) {
|
||||
name = numpadChar;
|
||||
if (!ctrl && !cmd && !alt) {
|
||||
sequence = numpadChar;
|
||||
insertable = true;
|
||||
}
|
||||
} else {
|
||||
name = 'undefined';
|
||||
if (
|
||||
codeNumber >= 'a'.charCodeAt(0) &&
|
||||
codeNumber <= 'z'.charCodeAt(0)
|
||||
(ctrl || cmd || alt) &&
|
||||
(code.endsWith('u') || code.endsWith('~'))
|
||||
) {
|
||||
name = String.fromCharCode(codeNumber);
|
||||
// CSI-u or tilde-coded functional keys: ESC [ <code> ; <mods> (u|~)
|
||||
const codeNumber = parseInt(code.slice(1, -1), 10);
|
||||
if (
|
||||
codeNumber >= 'a'.charCodeAt(0) &&
|
||||
codeNumber <= 'z'.charCodeAt(0)
|
||||
) {
|
||||
name = String.fromCharCode(codeNumber);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface UIActions {
|
||||
vimHandleInput: (key: Key) => boolean;
|
||||
handleIdePromptComplete: (result: IdeIntegrationNudgeResult) => void;
|
||||
handleFolderTrustSelect: (choice: FolderTrustChoice) => void;
|
||||
setIsPolicyUpdateDialogOpen: (value: boolean) => void;
|
||||
setConstrainHeight: (value: boolean) => void;
|
||||
onEscapePromptChange: (show: boolean) => void;
|
||||
refreshStatic: () => void;
|
||||
|
||||
@@ -27,6 +27,8 @@ import type {
|
||||
FallbackIntent,
|
||||
ValidationIntent,
|
||||
AgentDefinition,
|
||||
FolderDiscoveryResults,
|
||||
PolicyUpdateConfirmationRequest,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type TransientMessageType } from '../../utils/events.js';
|
||||
import type { DOMElement } from 'ink';
|
||||
@@ -112,6 +114,9 @@ export interface UIState {
|
||||
isResuming: boolean;
|
||||
shouldShowIdePrompt: boolean;
|
||||
isFolderTrustDialogOpen: boolean;
|
||||
folderDiscoveryResults: FolderDiscoveryResults | null;
|
||||
isPolicyUpdateDialogOpen: boolean;
|
||||
policyUpdateConfirmationRequest: PolicyUpdateConfirmationRequest | undefined;
|
||||
isTrustedFolder: boolean | undefined;
|
||||
constrainHeight: boolean;
|
||||
showErrorDetails: boolean;
|
||||
@@ -177,6 +182,7 @@ export interface UIState {
|
||||
isBackgroundShellListOpen: boolean;
|
||||
adminSettingsChanged: boolean;
|
||||
newAgents: AgentDefinition[] | null;
|
||||
showIsExpandableHint: boolean;
|
||||
hintMode: boolean;
|
||||
hintBuffer: string;
|
||||
transientMessage: {
|
||||
|
||||
@@ -166,9 +166,11 @@ async function searchResourceCandidates(
|
||||
const fzf = new AsyncFzf(candidates, {
|
||||
selector: (candidate: ResourceSuggestionCandidate) => candidate.searchKey,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
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,
|
||||
);
|
||||
@@ -188,9 +190,11 @@ async function searchAgentCandidates(
|
||||
const fzf = new AsyncFzf(candidates, {
|
||||
selector: (s: Suggestion) => s.label,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import {
|
||||
ExtensionRegistryClient,
|
||||
type RegistryExtension,
|
||||
} from '../../config/extensionRegistryClient.js';
|
||||
|
||||
export interface UseExtensionRegistryResult {
|
||||
extensions: RegistryExtension[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
search: (query: string) => void;
|
||||
}
|
||||
|
||||
export function useExtensionRegistry(
|
||||
initialQuery = '',
|
||||
): UseExtensionRegistryResult {
|
||||
const [extensions, setExtensions] = useState<RegistryExtension[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const client = useMemo(() => new ExtensionRegistryClient(), []);
|
||||
|
||||
// Ref to track the latest query to avoid race conditions
|
||||
const latestQueryRef = useRef(initialQuery);
|
||||
|
||||
// Ref for debounce timeout
|
||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
|
||||
const searchExtensions = useCallback(
|
||||
async (query: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const results = await client.searchExtensions(query);
|
||||
|
||||
// Only update if this is still the latest query
|
||||
if (query === latestQueryRef.current) {
|
||||
// Check if results are different from current extensions
|
||||
setExtensions((prev) => {
|
||||
if (
|
||||
prev.length === results.length &&
|
||||
prev.every((ext, i) => ext.id === results[i].id)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return results;
|
||||
});
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (query === latestQueryRef.current) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setExtensions([]);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[client],
|
||||
);
|
||||
|
||||
const search = useCallback(
|
||||
(query: string) => {
|
||||
latestQueryRef.current = query;
|
||||
|
||||
// Clear existing timeout
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
void searchExtensions(query);
|
||||
}, 300);
|
||||
},
|
||||
[searchExtensions],
|
||||
);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
void searchExtensions(initialQuery);
|
||||
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [initialQuery, searchExtensions]);
|
||||
|
||||
return {
|
||||
extensions,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
};
|
||||
}
|
||||
@@ -36,6 +36,9 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
return {
|
||||
...actual,
|
||||
isHeadlessMode: vi.fn().mockReturnValue(false),
|
||||
FolderTrustDiscoveryService: {
|
||||
discover: vi.fn(() => new Promise(() => {})),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,13 @@ import {
|
||||
} from '../../config/trustedFolders.js';
|
||||
import * as process from 'node:process';
|
||||
import { type HistoryItemWithoutId, MessageType } from '../types.js';
|
||||
import { coreEvents, ExitCodes, isHeadlessMode } from '@google/gemini-cli-core';
|
||||
import {
|
||||
coreEvents,
|
||||
ExitCodes,
|
||||
isHeadlessMode,
|
||||
FolderTrustDiscoveryService,
|
||||
type FolderDiscoveryResults,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
|
||||
export const useFolderTrust = (
|
||||
@@ -24,6 +30,8 @@ export const useFolderTrust = (
|
||||
) => {
|
||||
const [isTrusted, setIsTrusted] = useState<boolean | undefined>(undefined);
|
||||
const [isFolderTrustDialogOpen, setIsFolderTrustDialogOpen] = useState(false);
|
||||
const [discoveryResults, setDiscoveryResults] =
|
||||
useState<FolderDiscoveryResults | null>(null);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const startupMessageSent = useRef(false);
|
||||
|
||||
@@ -33,6 +41,19 @@ export const useFolderTrust = (
|
||||
let isMounted = true;
|
||||
const { isTrusted: trusted } = isWorkspaceTrusted(settings.merged);
|
||||
|
||||
if (trusted === undefined || trusted === false) {
|
||||
void FolderTrustDiscoveryService.discover(process.cwd())
|
||||
.then((results) => {
|
||||
if (isMounted) {
|
||||
setDiscoveryResults(results);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently ignore discovery errors as they are handled within the service
|
||||
// and reported via results.discoveryErrors if successful.
|
||||
});
|
||||
}
|
||||
|
||||
const showUntrustedMessage = () => {
|
||||
if (trusted === false && !startupMessageSent.current) {
|
||||
addItem(
|
||||
@@ -100,8 +121,6 @@ export const useFolderTrust = (
|
||||
onTrustChange(currentIsTrusted);
|
||||
setIsTrusted(currentIsTrusted);
|
||||
|
||||
// logic: we restart if the trust state *effectively* changes from the previous state.
|
||||
// previous state was `isTrusted`. If undefined, we assume false (untrusted).
|
||||
const wasTrusted = isTrusted ?? false;
|
||||
|
||||
if (wasTrusted !== currentIsTrusted) {
|
||||
@@ -117,6 +136,7 @@ export const useFolderTrust = (
|
||||
return {
|
||||
isTrusted,
|
||||
isFolderTrustDialogOpen,
|
||||
discoveryResults,
|
||||
handleFolderTrustSelect,
|
||||
isRestarting,
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user