mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-25 09:10:59 -07:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8956d77da4 | |||
| 05fe1bce97 | |||
| 237864eb63 | |||
| dc6741097c | |||
| dac3735626 | |||
| 245b68e9f1 | |||
| e89cf5d86e | |||
| 54b0344fc5 |
@@ -169,7 +169,7 @@ jobs:
|
||||
npm run test:ci --workspace @google/gemini-cli
|
||||
else
|
||||
# Explicitly list non-cli packages to ensure they are sharded correctly
|
||||
npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present
|
||||
npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present -- --coverage.enabled=false
|
||||
npm run test:scripts
|
||||
fi
|
||||
|
||||
|
||||
@@ -24,6 +24,21 @@ and parameters.
|
||||
| -------- | ----------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| `query` | string (variadic) | Positional prompt. Defaults to one-shot mode. Use `-i/--prompt-interactive` to execute and continue interactively. |
|
||||
|
||||
## Interactive commands
|
||||
|
||||
These commands are available within the interactive REPL.
|
||||
|
||||
| Command | Description |
|
||||
| -------------------- | ---------------------------------------- |
|
||||
| `/skills reload` | Reload discovered skills from disk |
|
||||
| `/agents reload` | Reload the agent registry |
|
||||
| `/commands reload` | Reload custom slash commands |
|
||||
| `/memory reload` | Reload context files (e.g., `GEMINI.md`) |
|
||||
| `/mcp reload` | Restart and reload MCP servers |
|
||||
| `/extensions reload` | Reload all active extensions |
|
||||
| `/help` | Show help for all commands |
|
||||
| `/quit` | Exit the interactive session |
|
||||
|
||||
## CLI Options
|
||||
|
||||
| Option | Alias | Type | Default | Description |
|
||||
|
||||
@@ -63,7 +63,7 @@ You can interact with the loaded context files by using the `/memory` command.
|
||||
- **`/memory show`**: Displays the full, concatenated content of the current
|
||||
hierarchical memory. This lets you inspect the exact instructional context
|
||||
being provided to the model.
|
||||
- **`/memory refresh`**: Forces a re-scan and reload of all `GEMINI.md` files
|
||||
- **`/memory reload`**: Forces a re-scan and reload of all `GEMINI.md` files
|
||||
from all configured locations.
|
||||
- **`/memory add <text>`**: Appends your text to your global
|
||||
`~/.gemini/GEMINI.md` file. This lets you add persistent memories on the fly.
|
||||
|
||||
@@ -102,7 +102,7 @@ they appear in the UI.
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Memory Discovery Max Dirs | `context.discoveryMaxDirs` | Maximum number of directories to search for memory. | `200` |
|
||||
| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` |
|
||||
| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory reload loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` |
|
||||
| Respect .gitignore | `context.fileFiltering.respectGitIgnore` | Respect .gitignore files when searching. | `true` |
|
||||
| Respect .geminiignore | `context.fileFiltering.respectGeminiIgnore` | Respect .geminiignore files when searching. | `true` |
|
||||
| Enable Recursive File Search | `context.fileFiltering.enableRecursiveFileSearch` | Enable recursive file search functionality when completing @ references in the prompt. | `true` |
|
||||
|
||||
@@ -101,8 +101,8 @@ The agent will:
|
||||
|
||||
- **Server won't start?** Try running the docker command manually in your
|
||||
terminal to see if it prints an error (e.g., "image not found").
|
||||
- **Tools not found?** Run `/mcp refresh` to force the CLI to re-query the
|
||||
server for its capabilities.
|
||||
- **Tools not found?** Run `/mcp reload` to force the CLI to re-query the server
|
||||
for its capabilities.
|
||||
|
||||
## Next steps
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ excellent for debugging why the agent might be ignoring a rule.
|
||||
If you edit a `GEMINI.md` file while a session is running, the agent won't know
|
||||
immediately. Force a reload with:
|
||||
|
||||
**Command:** `/memory refresh`
|
||||
**Command:** `/memory reload`
|
||||
|
||||
## Best practices
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ Markdown file.
|
||||
Users can manage subagents using the following commands within the Gemini CLI:
|
||||
|
||||
- `/agents list`: Displays all available local and remote subagents.
|
||||
- `/agents refresh`: Reloads the agent registry. Use this after adding or
|
||||
- `/agents reload`: Reloads the agent registry. Use this after adding or
|
||||
modifying agent definition files.
|
||||
- `/agents enable <agent_name>`: Enables a specific subagent.
|
||||
- `/agents disable <agent_name>`: Disables a specific subagent.
|
||||
|
||||
@@ -719,7 +719,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `[]`
|
||||
|
||||
- **`context.loadMemoryFromIncludeDirectories`** (boolean):
|
||||
- **Description:** Controls how /memory refresh loads GEMINI.md files. When
|
||||
- **Description:** Controls how /memory reload loads GEMINI.md files. When
|
||||
true, include directories are scanned; when false, only the current
|
||||
directory is used.
|
||||
- **Default:** `false`
|
||||
@@ -1705,7 +1705,7 @@ conventions and context.
|
||||
loaded, allowing you to verify the hierarchy and content being used by the
|
||||
AI.
|
||||
- See the [Commands documentation](./commands.md#memory) for full details on
|
||||
the `/memory` command and its sub-commands (`show` and `refresh`).
|
||||
the `/memory` command and its sub-commands (`show` and `reload`).
|
||||
|
||||
By understanding and utilizing these configuration layers and the hierarchical
|
||||
nature of context files, you can effectively manage the AI's memory and tailor
|
||||
|
||||
+11
-1
@@ -132,7 +132,16 @@ export default tseslint.config(
|
||||
'no-cond-assign': 'error',
|
||||
'no-debugger': 'error',
|
||||
'no-duplicate-case': 'error',
|
||||
'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules],
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
...commonRestrictedSyntaxRules,
|
||||
{
|
||||
selector:
|
||||
'UnaryExpression[operator="typeof"] > MemberExpression[computed=true][property.type="Literal"]',
|
||||
message:
|
||||
'Do not use typeof to check object properties. Define a TypeScript interface and a type guard function instead.',
|
||||
},
|
||||
],
|
||||
'no-unsafe-finally': 'error',
|
||||
'no-unused-expressions': 'off', // Disable base rule
|
||||
'@typescript-eslint/no-unused-expressions': [
|
||||
@@ -263,6 +272,7 @@ export default tseslint.config(
|
||||
...vitest.configs.recommended.rules,
|
||||
'vitest/expect-expect': 'off',
|
||||
'vitest/no-commented-out-tests': 'off',
|
||||
'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -832,7 +832,9 @@ export class Task {
|
||||
if (
|
||||
part.kind !== 'data' ||
|
||||
!part.data ||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof part.data['callId'] !== 'string' ||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof part.data['outcome'] !== 'string'
|
||||
) {
|
||||
return false;
|
||||
|
||||
@@ -79,6 +79,7 @@ function migrateClaudeHook(claudeHook: unknown): unknown {
|
||||
migrated['command'] = hook['command'];
|
||||
|
||||
// Replace CLAUDE_PROJECT_DIR with GEMINI_PROJECT_DIR in command
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
if (typeof migrated['command'] === 'string') {
|
||||
migrated['command'] = migrated['command'].replace(
|
||||
/\$CLAUDE_PROJECT_DIR/g,
|
||||
@@ -93,6 +94,7 @@ function migrateClaudeHook(claudeHook: unknown): unknown {
|
||||
}
|
||||
|
||||
// Map timeout field (Claude uses seconds, Gemini uses seconds)
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
if ('timeout' in hook && typeof hook['timeout'] === 'number') {
|
||||
migrated['timeout'] = hook['timeout'];
|
||||
}
|
||||
@@ -140,6 +142,7 @@ function migrateClaudeHooks(claudeConfig: unknown): Record<string, unknown> {
|
||||
// Transform matcher
|
||||
if (
|
||||
'matcher' in definition &&
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof definition['matcher'] === 'string'
|
||||
) {
|
||||
migratedDef['matcher'] = transformMatcher(definition['matcher']);
|
||||
|
||||
@@ -183,7 +183,6 @@ describe('resolveWorkspacePolicyState', () => {
|
||||
setAutoAcceptWorkspacePolicies(originalValue);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not return workspace policies if cwd is the home directory', async () => {
|
||||
const policiesDir = path.join(tempDir, '.gemini', 'policies');
|
||||
fs.mkdirSync(policiesDir, { recursive: true });
|
||||
|
||||
@@ -1169,7 +1169,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: oneLine`
|
||||
Controls how /memory refresh loads GEMINI.md files.
|
||||
Controls how /memory reload loads GEMINI.md files.
|
||||
When true, include directories are scanned; when false, only the current directory is used.
|
||||
`,
|
||||
showInDialog: true,
|
||||
|
||||
@@ -65,6 +65,7 @@ export function mockCoreDebugLogger<T extends Record<string, unknown>>(
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
...(typeof actual['coreEvents'] === 'object' &&
|
||||
actual['coreEvents'] !== null
|
||||
? actual['coreEvents']
|
||||
|
||||
@@ -96,6 +96,7 @@ function isInkRenderMetrics(
|
||||
typeof m === 'object' &&
|
||||
m !== null &&
|
||||
'output' in m &&
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof m['output'] === 'string'
|
||||
);
|
||||
}
|
||||
@@ -605,7 +606,6 @@ const mockUIActions: UIActions = {
|
||||
handleFolderTrustSelect: vi.fn(),
|
||||
setIsPolicyUpdateDialogOpen: vi.fn(),
|
||||
setConstrainHeight: vi.fn(),
|
||||
onEscapePromptChange: vi.fn(),
|
||||
refreshStatic: vi.fn(),
|
||||
handleFinalSubmit: vi.fn(),
|
||||
handleClearScreen: vi.fn(),
|
||||
|
||||
@@ -9,6 +9,7 @@ import type React from 'react';
|
||||
import { renderWithProviders } from '../test-utils/render.js';
|
||||
import { Text, useIsScreenReaderEnabled, type DOMElement } from 'ink';
|
||||
import { App } from './App.js';
|
||||
import { TransientMessageType } from '../utils/events.js';
|
||||
import { type UIState } from './contexts/UIStateContext.js';
|
||||
import { StreamingState } from './types.js';
|
||||
import { makeFakeConfig, CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
@@ -170,16 +171,16 @@ describe('App', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ key: 'C', stateKey: 'ctrlCPressedOnce' },
|
||||
{ key: 'D', stateKey: 'ctrlDPressedOnce' },
|
||||
{ key: 'C' },
|
||||
{ key: 'D' },
|
||||
])(
|
||||
'should show Ctrl+$key exit prompt when dialogs are visible and $stateKey is true',
|
||||
async ({ key, stateKey }) => {
|
||||
'should show Ctrl+$key exit prompt when dialogs are visible and warning is present',
|
||||
async ({ key }) => {
|
||||
const uiState = {
|
||||
...mockUIState,
|
||||
dialogsVisible: true,
|
||||
[stateKey]: true,
|
||||
} as UIState;
|
||||
transientMessage: { message: `Press Ctrl+${key} again to exit.`, type: TransientMessageType.Warning },
|
||||
} as unknown as UIState;
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<App />,
|
||||
|
||||
@@ -192,11 +192,18 @@ vi.mock('../utils/terminalNotifications.js', () => ({
|
||||
vi.mock('./hooks/useTerminalTheme.js', () => ({
|
||||
useTerminalTheme: vi.fn(),
|
||||
}));
|
||||
vi.mock('./hooks/useAlternateBuffer.js', () => ({
|
||||
useAlternateBuffer: vi.fn().mockReturnValue(false),
|
||||
useLegacyNonAlternateBufferMode: vi.fn().mockReturnValue(false),
|
||||
isAlternateBufferEnabled: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
|
||||
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
||||
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
|
||||
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
|
||||
import { useFocus } from './hooks/useFocus.js';
|
||||
import { TransientMessageType } from '../utils/events.js';
|
||||
|
||||
// Mock external utilities
|
||||
vi.mock('../utils/events.js');
|
||||
@@ -232,10 +239,7 @@ import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
||||
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
||||
import * as useKeypressModule from './hooks/useKeypress.js';
|
||||
import { useSuspend } from './hooks/useSuspend.js';
|
||||
import { measureElement } from 'ink';
|
||||
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||
import {
|
||||
ShellExecutionService,
|
||||
writeToStdout,
|
||||
enableMouseEvents,
|
||||
disableMouseEvents,
|
||||
@@ -2143,19 +2147,19 @@ describe('AppContainer State Management', () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
|
||||
expect(capturedUIState.queueErrorMessage).toBeNull();
|
||||
expect(capturedUIState.transientMessage).toBeNull();
|
||||
|
||||
act(() => {
|
||||
capturedUIActions.setQueueErrorMessage('Test error');
|
||||
});
|
||||
rerender(getAppContainer());
|
||||
expect(capturedUIState.queueErrorMessage).toBe('Test error');
|
||||
await waitFor(() => expect(capturedUIState.transientMessage?.message).toBe('Test error'));
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
rerender(getAppContainer());
|
||||
expect(capturedUIState.queueErrorMessage).toBeNull();
|
||||
expect(capturedUIState.transientMessage).toBeNull();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -2169,7 +2173,7 @@ describe('AppContainer State Management', () => {
|
||||
capturedUIActions.setQueueErrorMessage('First error');
|
||||
});
|
||||
rerender(getAppContainer());
|
||||
expect(capturedUIState.queueErrorMessage).toBe('First error');
|
||||
await waitFor(() => expect(capturedUIState.transientMessage?.message).toBe('First error'));
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
@@ -2179,53 +2183,24 @@ describe('AppContainer State Management', () => {
|
||||
capturedUIActions.setQueueErrorMessage('Second error');
|
||||
});
|
||||
rerender(getAppContainer());
|
||||
expect(capturedUIState.queueErrorMessage).toBe('Second error');
|
||||
await waitFor(() => expect(capturedUIState.transientMessage?.message).toBe('Second error'));
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
rerender(getAppContainer());
|
||||
expect(capturedUIState.queueErrorMessage).toBe('Second error');
|
||||
await waitFor(() => expect(capturedUIState.transientMessage?.message).toBe('Second error'));
|
||||
|
||||
// 5. Advance time past the 3 second timeout from the second message
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
rerender(getAppContainer());
|
||||
expect(capturedUIState.queueErrorMessage).toBeNull();
|
||||
expect(capturedUIState.transientMessage).toBeNull();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Terminal Height Calculation', () => {
|
||||
const mockedMeasureElement = measureElement as Mock;
|
||||
const mockedUseTerminalSize = useTerminalSize as Mock;
|
||||
|
||||
it('should prevent terminal height from being less than 1', async () => {
|
||||
const resizePtySpy = vi.spyOn(ShellExecutionService, 'resizePty');
|
||||
// Arrange: Simulate a small terminal and a large footer
|
||||
mockedUseTerminalSize.mockReturnValue({ columns: 80, rows: 5 });
|
||||
mockedMeasureElement.mockReturnValue({ width: 80, height: 10 }); // Footer is taller than the screen
|
||||
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: 'some-id',
|
||||
});
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(resizePtySpy).toHaveBeenCalled());
|
||||
const lastCall =
|
||||
resizePtySpy.mock.calls[resizePtySpy.mock.calls.length - 1];
|
||||
// Check the height argument specifically
|
||||
expect(lastCall[2]).toBe(1);
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => {
|
||||
let mockHandleSlashCommand: Mock;
|
||||
let mockCancelOngoingRequest: Mock;
|
||||
@@ -3141,30 +3116,6 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shell Interaction', () => {
|
||||
it('should not crash if resizing the pty fails', async () => {
|
||||
const resizePtySpy = vi
|
||||
.spyOn(ShellExecutionService, 'resizePty')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('Cannot resize a pty that has already exited');
|
||||
});
|
||||
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: 'some-pty-id', // Make sure activePtyId is set
|
||||
});
|
||||
|
||||
// The main assertion is that the render does not throw.
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
|
||||
await waitFor(() => expect(resizePtySpy).toHaveBeenCalled());
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
describe('Banner Text', () => {
|
||||
it('should render placeholder banner text for USE_GEMINI auth type', async () => {
|
||||
const config = makeFakeConfig();
|
||||
@@ -3445,21 +3396,78 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show hint because we are in Standard Mode (default settings) and have overflow
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
|
||||
});
|
||||
|
||||
// Advance just before the timeout
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
|
||||
|
||||
// Advance to hit the timeout mark
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(false);
|
||||
expect(capturedUIState.transientMessage).toBeNull();
|
||||
});
|
||||
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('resets the hint timer when a new component overflows (overflowingIdsSize increases)', async () => {
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
// 1. Trigger first overflow
|
||||
act(() => {
|
||||
capturedOverflowActions.addOverflowingId('test-id-1');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
|
||||
});
|
||||
|
||||
// 2. Advance half the duration
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
|
||||
});
|
||||
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
|
||||
|
||||
// 3. Trigger second overflow (this should reset the timer)
|
||||
act(() => {
|
||||
capturedOverflowActions.addOverflowingId('test-id-2');
|
||||
});
|
||||
|
||||
// Advance by 1ms to allow the OverflowProvider's 0ms batching timeout to fire
|
||||
// and flush the state update to AppContainer, triggering the reset.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
|
||||
});
|
||||
|
||||
// 4. Advance enough that the ORIGINAL timer would have expired
|
||||
// Subtracting 1ms since we advanced it above to flush the state.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 100 - 1);
|
||||
});
|
||||
// The hint should STILL be visible because the timer reset at step 3
|
||||
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
|
||||
|
||||
// 5. Advance to the end of the NEW timer
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 100);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.transientMessage).toBeNull();
|
||||
});
|
||||
|
||||
unmount!();
|
||||
@@ -3484,14 +3492,14 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
|
||||
});
|
||||
|
||||
// Advance half the duration
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
|
||||
|
||||
// Simulate Ctrl+O
|
||||
act(() => {
|
||||
@@ -3509,7 +3517,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
// We expect it to still be true because Ctrl+O should have reset the timer
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
|
||||
|
||||
// Advance remaining time to reach the new timeout
|
||||
act(() => {
|
||||
@@ -3517,7 +3525,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(false);
|
||||
expect(capturedUIState.transientMessage).toBeNull();
|
||||
});
|
||||
|
||||
unmount!();
|
||||
@@ -3542,14 +3550,14 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
|
||||
});
|
||||
|
||||
// Advance half the duration
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
|
||||
|
||||
// First toggle 'on' (expanded)
|
||||
act(() => {
|
||||
@@ -3563,7 +3571,7 @@ describe('AppContainer State Management', () => {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
|
||||
|
||||
// Second toggle 'off' (collapsed)
|
||||
act(() => {
|
||||
@@ -3577,7 +3585,7 @@ describe('AppContainer State Management', () => {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
|
||||
|
||||
// Third toggle 'on' (expanded)
|
||||
act(() => {
|
||||
@@ -3592,7 +3600,7 @@ describe('AppContainer State Management', () => {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
|
||||
|
||||
// Wait 0.1s more to hit exactly the timeout since the last toggle.
|
||||
// It should hide now.
|
||||
@@ -3600,13 +3608,13 @@ describe('AppContainer State Management', () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(false);
|
||||
expect(capturedUIState.transientMessage).toBeNull();
|
||||
});
|
||||
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('does NOT set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => {
|
||||
it('DOES set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => {
|
||||
const alternateSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
const settingsWithAlternateBuffer = {
|
||||
merged: {
|
||||
@@ -3635,7 +3643,9 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
// Should NOT show hint because we are in Alternate Buffer Mode
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(false);
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.transientMessage).toBeNull();
|
||||
});
|
||||
|
||||
unmount!();
|
||||
});
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useLegacyNonAlternateBufferMode } from './hooks/useAlternateBuffer.js';
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
useMemo,
|
||||
useState,
|
||||
@@ -13,6 +20,7 @@ import {
|
||||
useLayoutEffect,
|
||||
} from 'react';
|
||||
import {
|
||||
Box,
|
||||
type DOMElement,
|
||||
measureElement,
|
||||
useApp,
|
||||
@@ -120,12 +128,18 @@ import { useFocus } from './hooks/useFocus.js';
|
||||
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
||||
import { KeypressPriority } from './contexts/KeypressContext.js';
|
||||
import { keyMatchers, Command } from './keyMatchers.js';
|
||||
import { formatCommand } from './utils/keybindingUtils.js';
|
||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
|
||||
import { useFolderTrust } from './hooks/useFolderTrust.js';
|
||||
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
|
||||
import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
|
||||
import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js';
|
||||
import {
|
||||
appEvents,
|
||||
AppEvent,
|
||||
TransientMessageType,
|
||||
type TransientMessagePayload,
|
||||
} from '../utils/events.js';
|
||||
import { type UpdateObject } from './utils/updateCheck.js';
|
||||
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
|
||||
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
|
||||
@@ -230,6 +244,89 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
useMemoryMonitor(historyManager);
|
||||
const isAlternateBuffer = config.getUseAlternateBuffer();
|
||||
|
||||
const rootUiRef = useRef<DOMElement>(null);
|
||||
const isLegacyNonAlternateBufferMode =
|
||||
useLegacyNonAlternateBufferMode(rootUiRef, isAlternateBuffer);
|
||||
|
||||
const [transientMessage, setTransientMessageInternal] = useTimedMessage<{
|
||||
message: string;
|
||||
type: TransientMessageType;
|
||||
}>(WARNING_PROMPT_DURATION_MS, isLegacyNonAlternateBufferMode);
|
||||
|
||||
const currentlyShowingTypeRef = useRef<TransientMessageType | null>(null);
|
||||
|
||||
const [transientMessageQueue, setTransientMessageQueue] = useState<
|
||||
TransientMessagePayload[]
|
||||
>([]);
|
||||
|
||||
const showTransientMessage = useCallback(
|
||||
(payload: TransientMessagePayload | null, durationMs?: number) => {
|
||||
if (payload === null) {
|
||||
setTransientMessageInternal(null);
|
||||
setTransientMessageQueue([]);
|
||||
currentlyShowingTypeRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentlyShowingTypeRef.current === payload.type) {
|
||||
setTransientMessageInternal(
|
||||
{ message: payload.message, type: payload.type },
|
||||
durationMs,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setTransientMessageQueue((prev) => [...prev, { ...payload, durationMs }]);
|
||||
},
|
||||
[setTransientMessageInternal],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLegacyNonAlternateBufferMode) {
|
||||
return;
|
||||
}
|
||||
if (transientMessageQueue.length > 0 && !transientMessage) {
|
||||
const [next, ...rest] = transientMessageQueue;
|
||||
currentlyShowingTypeRef.current = next.type;
|
||||
setTransientMessageInternal(
|
||||
{ message: next.message, type: next.type },
|
||||
next.durationMs,
|
||||
);
|
||||
setTransientMessageQueue(rest);
|
||||
} else if (!transientMessage) {
|
||||
currentlyShowingTypeRef.current = null;
|
||||
}
|
||||
}, [
|
||||
transientMessageQueue,
|
||||
transientMessage,
|
||||
setTransientMessageInternal,
|
||||
isLegacyNonAlternateBufferMode,
|
||||
]);
|
||||
|
||||
const handleSetConstrainHeight = useCallback(
|
||||
(value: boolean) => {
|
||||
setConstrainHeight(value);
|
||||
showTransientMessage(
|
||||
{
|
||||
message: `Ctrl+O to ${!value ? 'show more' : 'collapse'} lines of the last response`,
|
||||
type: TransientMessageType.Accent,
|
||||
},
|
||||
EXPAND_HINT_DURATION_MS,
|
||||
);
|
||||
},
|
||||
[showTransientMessage],
|
||||
);
|
||||
|
||||
const handleSetQueueErrorMessage = useCallback(
|
||||
(message: string | null) => {
|
||||
showTransientMessage(
|
||||
message ? { message, type: TransientMessageType.Error } : null,
|
||||
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||
);
|
||||
},
|
||||
[showTransientMessage],
|
||||
);
|
||||
const [corgiMode, setCorgiMode] = useState(false);
|
||||
const [forceRerenderKey, setForceRerenderKey] = useState(0);
|
||||
const [debugMessage, setDebugMessage] = useState<string>('');
|
||||
@@ -265,16 +362,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
() => isWorkspaceTrusted(settings.merged).isTrusted,
|
||||
);
|
||||
|
||||
const [queueErrorMessage, setQueueErrorMessage] = useTimedMessage<string>(
|
||||
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||
);
|
||||
|
||||
const [newAgents, setNewAgents] = useState<AgentDefinition[] | null>(null);
|
||||
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
|
||||
const [expandHintTrigger, triggerExpandHint] = useTimedMessage<boolean>(
|
||||
EXPAND_HINT_DURATION_MS,
|
||||
);
|
||||
const showIsExpandableHint = Boolean(expandHintTrigger);
|
||||
const overflowState = useOverflowState();
|
||||
const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0;
|
||||
const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight;
|
||||
@@ -283,19 +373,30 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
* 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.
|
||||
* or the user manually toggles the expansion state with Ctrl+O.
|
||||
* By depending on overflowingIdsSize, the timer resets when *new* views
|
||||
* overflow, but avoids infinitely resetting during single-view streaming.
|
||||
*
|
||||
* In alternate buffer mode, we don't trigger the hint automatically on overflow
|
||||
* to avoid noise, but the user can still trigger it manually with Ctrl+O.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (hasOverflowState && !isAlternateBuffer) {
|
||||
triggerExpandHint(true);
|
||||
showTransientMessage(
|
||||
{
|
||||
message: `Ctrl+O to ${constrainHeight ? 'show more' : 'collapse'} lines of the last response`,
|
||||
type: TransientMessageType.Accent,
|
||||
},
|
||||
EXPAND_HINT_DURATION_MS,
|
||||
);
|
||||
}
|
||||
}, [hasOverflowState, isAlternateBuffer, triggerExpandHint]);
|
||||
}, [
|
||||
hasOverflowState,
|
||||
isAlternateBuffer,
|
||||
constrainHeight,
|
||||
showTransientMessage,
|
||||
overflowingIdsSize,
|
||||
]);
|
||||
|
||||
const [defaultBannerText, setDefaultBannerText] = useState('');
|
||||
const [warningBannerText, setWarningBannerText] = useState('');
|
||||
@@ -416,7 +517,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Layout measurements
|
||||
const mainControlsRef = useRef<DOMElement>(null);
|
||||
// For performance profiling only
|
||||
const rootUiRef = useRef<DOMElement>(null);
|
||||
const lastTitleRef = useRef<string | null>(null);
|
||||
const staticExtraHeight = 3;
|
||||
|
||||
@@ -1010,10 +1110,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Memory refreshed successfully. ${
|
||||
text: `Memory reloaded successfully. ${
|
||||
flattenedMemory.length > 0
|
||||
? `Loaded ${flattenedMemory.length} characters from ${fileCount} file(s).`
|
||||
: 'No memory content found.'
|
||||
? `Loaded ${flattenedMemory.length} characters from ${fileCount} file(s)`
|
||||
: 'No memory content found'
|
||||
}`,
|
||||
},
|
||||
Date.now(),
|
||||
@@ -1261,7 +1361,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
async (submittedValue: string) => {
|
||||
reset();
|
||||
// Explicitly hide the expansion hint and clear its x-second timer when a new turn begins.
|
||||
triggerExpandHint(null);
|
||||
showTransientMessage(null);
|
||||
if (!constrainHeight) {
|
||||
setConstrainHeight(true);
|
||||
if (!isAlternateBuffer) {
|
||||
@@ -1323,24 +1423,24 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
submitQuery,
|
||||
isMcpReady,
|
||||
streamingState,
|
||||
messageQueue.length,
|
||||
pendingSlashCommandHistoryItems,
|
||||
pendingGeminiHistoryItems,
|
||||
config,
|
||||
constrainHeight,
|
||||
setConstrainHeight,
|
||||
handleSetConstrainHeight,
|
||||
isAlternateBuffer,
|
||||
isAgentConfigDialogOpen,
|
||||
refreshStatic,
|
||||
reset,
|
||||
handleHintSubmit,
|
||||
triggerExpandHint,
|
||||
showTransientMessage,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClearScreen = useCallback(() => {
|
||||
reset();
|
||||
// Explicitly hide the expansion hint and clear its x-second timer when clearing the screen.
|
||||
triggerExpandHint(null);
|
||||
showTransientMessage(null);
|
||||
historyManager.clearItems();
|
||||
clearConsoleMessagesState();
|
||||
refreshStatic();
|
||||
@@ -1349,7 +1449,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
clearConsoleMessagesState,
|
||||
refreshStatic,
|
||||
reset,
|
||||
triggerExpandHint,
|
||||
showTransientMessage,
|
||||
]);
|
||||
|
||||
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
|
||||
@@ -1421,32 +1521,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const initialPromptSubmitted = useRef(false);
|
||||
const geminiClient = config.getGeminiClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (activePtyId) {
|
||||
try {
|
||||
ShellExecutionService.resizePty(
|
||||
activePtyId,
|
||||
Math.floor(terminalWidth * SHELL_WIDTH_FRACTION),
|
||||
Math.max(
|
||||
Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING),
|
||||
1,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// This can happen in a race condition where the pty exits
|
||||
// right before we try to resize it.
|
||||
if (
|
||||
!(
|
||||
e instanceof Error &&
|
||||
e.message.includes('Cannot resize a pty that has already exited')
|
||||
)
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [terminalWidth, availableTerminalHeight, activePtyId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
initialPrompt &&
|
||||
@@ -1498,39 +1572,39 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const [renderMarkdown, setRenderMarkdown] = useState<boolean>(true);
|
||||
|
||||
const handleExitRepeat = useCallback(
|
||||
(count: number) => {
|
||||
(count: number, message: string) => {
|
||||
if (count > 2) {
|
||||
recordExitFail(config);
|
||||
}
|
||||
if (count > 1) {
|
||||
void handleSlashCommand('/quit', undefined, undefined, false);
|
||||
} else if (count === 1) {
|
||||
showTransientMessage({
|
||||
message,
|
||||
type: TransientMessageType.Warning,
|
||||
});
|
||||
}
|
||||
},
|
||||
[config, handleSlashCommand],
|
||||
[config, handleSlashCommand, showTransientMessage],
|
||||
);
|
||||
|
||||
const { pressCount: ctrlCPressCount, handlePress: handleCtrlCPress } =
|
||||
const { handlePress: handleCtrlCPress } =
|
||||
useRepeatedKeyPress({
|
||||
windowMs: WARNING_PROMPT_DURATION_MS,
|
||||
onRepeat: handleExitRepeat,
|
||||
onRepeat: (count) => handleExitRepeat(count, 'Press Ctrl+C again to exit.'),
|
||||
});
|
||||
|
||||
const { pressCount: ctrlDPressCount, handlePress: handleCtrlDPress } =
|
||||
const { handlePress: handleCtrlDPress } =
|
||||
useRepeatedKeyPress({
|
||||
windowMs: WARNING_PROMPT_DURATION_MS,
|
||||
onRepeat: handleExitRepeat,
|
||||
onRepeat: (count) => handleExitRepeat(count, 'Press Ctrl+D again to exit.'),
|
||||
});
|
||||
|
||||
const [ideContextState, setIdeContextState] = useState<
|
||||
IdeContext | undefined
|
||||
>();
|
||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||
const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false);
|
||||
const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false);
|
||||
|
||||
const [transientMessage, showTransientMessage] = useTimedMessage<{
|
||||
text: string;
|
||||
type: TransientMessageType;
|
||||
}>(WARNING_PROMPT_DURATION_MS);
|
||||
|
||||
const {
|
||||
isFolderTrustDialogOpen,
|
||||
@@ -1559,20 +1633,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
message: string;
|
||||
type: TransientMessageType;
|
||||
}) => {
|
||||
showTransientMessage({ text: payload.message, type: payload.type });
|
||||
showTransientMessage({ message: payload.message, type: payload.type }, payload.durationMs);
|
||||
};
|
||||
|
||||
const handleSelectionWarning = () => {
|
||||
showTransientMessage({
|
||||
text: 'Press Ctrl-S to enter selection mode to copy text.',
|
||||
type: TransientMessageType.Warning,
|
||||
});
|
||||
showTransientMessage({ message: 'Press Ctrl-S to enter selection mode to copy text.', type: TransientMessageType.Warning });
|
||||
};
|
||||
const handlePasteTimeout = () => {
|
||||
showTransientMessage({
|
||||
text: 'Paste Timed out. Possibly due to slow connection.',
|
||||
type: TransientMessageType.Warning,
|
||||
});
|
||||
showTransientMessage({ message: 'Paste Timed out. Possibly due to slow connection.', type: TransientMessageType.Warning });
|
||||
};
|
||||
|
||||
appEvents.on(AppEvent.TransientMessage, handleTransientMessage);
|
||||
@@ -1591,10 +1659,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const handleWarning = useCallback(
|
||||
(message: string) => {
|
||||
showTransientMessage({
|
||||
text: message,
|
||||
type: TransientMessageType.Warning,
|
||||
});
|
||||
showTransientMessage({ message, type: TransientMessageType.Warning });
|
||||
},
|
||||
[showTransientMessage],
|
||||
);
|
||||
@@ -1647,9 +1712,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
const handleEscapePromptChange = useCallback((showPrompt: boolean) => {
|
||||
setShowEscapePrompt(showPrompt);
|
||||
}, []);
|
||||
|
||||
const handleIdePromptComplete = useCallback(
|
||||
(result: IdeIntegrationNudgeResult) => {
|
||||
@@ -1707,20 +1769,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
keyMatchers[Command.TOGGLE_COPY_MODE](key) &&
|
||||
!isAlternateBuffer
|
||||
) {
|
||||
showTransientMessage({
|
||||
text: 'Use Ctrl+O to expand and collapse blocks of content.',
|
||||
type: TransientMessageType.Warning,
|
||||
});
|
||||
showTransientMessage({ message: 'Use Ctrl+O to expand and collapse blocks of content.', type: TransientMessageType.Warning });
|
||||
return true;
|
||||
}
|
||||
|
||||
let enteringConstrainHeightMode = false;
|
||||
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.
|
||||
triggerExpandHint(true);
|
||||
handleSetConstrainHeight(true);
|
||||
} else {
|
||||
setConstrainHeight(true);
|
||||
}
|
||||
if (!isAlternateBuffer) {
|
||||
refreshStatic();
|
||||
@@ -1750,7 +1809,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
} else if (keyMatchers[Command.TOGGLE_MARKDOWN](key)) {
|
||||
setRenderMarkdown((prev) => {
|
||||
const newValue = !prev;
|
||||
// Force re-render of static content
|
||||
showTransientMessage(
|
||||
{
|
||||
message: newValue
|
||||
? 'rendered markdown mode'
|
||||
: `raw markdown mode (${formatCommand(Command.TOGGLE_MARKDOWN)} to toggle)`,
|
||||
type: TransientMessageType.Accent,
|
||||
},
|
||||
EXPAND_HINT_DURATION_MS,
|
||||
);
|
||||
refreshStatic();
|
||||
return newValue;
|
||||
});
|
||||
@@ -1767,9 +1834,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
keyMatchers[Command.SHOW_MORE_LINES](key) &&
|
||||
!enteringConstrainHeightMode
|
||||
) {
|
||||
setConstrainHeight(false);
|
||||
// If the user manually expands the view, show the hint and reset the x-second timer.
|
||||
triggerExpandHint(true);
|
||||
handleSetConstrainHeight(false);
|
||||
if (!isAlternateBuffer) {
|
||||
refreshStatic();
|
||||
}
|
||||
@@ -1787,10 +1852,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (lastOutputTimeRef.current === capturedTime) {
|
||||
setEmbeddedShellFocused(false);
|
||||
} else {
|
||||
showTransientMessage({
|
||||
text: 'Use Shift+Tab to unfocus',
|
||||
type: TransientMessageType.Warning,
|
||||
});
|
||||
showTransientMessage({ message: 'Use Shift+Tab to unfocus', type: TransientMessageType.Warning });
|
||||
}
|
||||
}, 150);
|
||||
return false;
|
||||
@@ -1848,7 +1910,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
},
|
||||
[
|
||||
constrainHeight,
|
||||
setConstrainHeight,
|
||||
handleSetConstrainHeight,
|
||||
setShowErrorDetails,
|
||||
config,
|
||||
ideContextState,
|
||||
@@ -1863,7 +1925,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
refreshStatic,
|
||||
setCopyModeEnabled,
|
||||
tabFocusTimeoutRef,
|
||||
isAlternateBuffer,
|
||||
shortcutsHelpVisible,
|
||||
backgroundCurrentShell,
|
||||
toggleBackgroundShell,
|
||||
@@ -1874,7 +1935,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
showTransientMessage,
|
||||
settings.merged.general.devtools,
|
||||
showErrorDetails,
|
||||
triggerExpandHint,
|
||||
showTransientMessage,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2228,9 +2289,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
filteredConsoleMessages,
|
||||
ideContextState,
|
||||
renderMarkdown,
|
||||
ctrlCPressedOnce: ctrlCPressCount >= 1,
|
||||
ctrlDPressedOnce: ctrlDPressCount >= 1,
|
||||
showEscapePrompt,
|
||||
shortcutsHelpVisible,
|
||||
cleanUiDetailsVisible,
|
||||
isFocused,
|
||||
@@ -2239,7 +2297,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
historyRemountKey,
|
||||
activeHooks,
|
||||
messageQueue,
|
||||
queueErrorMessage,
|
||||
showApprovalModeIndicator,
|
||||
allowPlanMode,
|
||||
currentModel,
|
||||
@@ -2280,7 +2337,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
showDebugProfiler,
|
||||
customDialog,
|
||||
copyModeEnabled,
|
||||
transientMessage,
|
||||
bannerData,
|
||||
bannerVisible,
|
||||
terminalBackgroundColor: config.getTerminalBackground(),
|
||||
@@ -2291,7 +2347,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isBackgroundShellListOpen,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
hintMode:
|
||||
config.isModelSteeringEnabled() &&
|
||||
isToolExecuting([
|
||||
@@ -2299,6 +2354,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
...pendingGeminiHistoryItems,
|
||||
]),
|
||||
hintBuffer: '',
|
||||
transientMessage: transientMessage
|
||||
? { message: transientMessage.message, type: transientMessage.type }
|
||||
: null,
|
||||
}),
|
||||
[
|
||||
isThemeDialogOpen,
|
||||
@@ -2318,7 +2376,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isSettingsDialogOpen,
|
||||
isSessionBrowserOpen,
|
||||
isModelDialogOpen,
|
||||
isAgentConfigDialogOpen,
|
||||
selectedAgentName,
|
||||
selectedAgentDisplayName,
|
||||
selectedAgentDefinition,
|
||||
@@ -2356,9 +2413,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
filteredConsoleMessages,
|
||||
ideContextState,
|
||||
renderMarkdown,
|
||||
ctrlCPressCount,
|
||||
ctrlDPressCount,
|
||||
showEscapePrompt,
|
||||
shortcutsHelpVisible,
|
||||
cleanUiDetailsVisible,
|
||||
isFocused,
|
||||
@@ -2367,7 +2421,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
historyRemountKey,
|
||||
activeHooks,
|
||||
messageQueue,
|
||||
queueErrorMessage,
|
||||
showApprovalModeIndicator,
|
||||
allowPlanMode,
|
||||
userTier,
|
||||
@@ -2419,7 +2472,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
backgroundShells,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
transientMessage,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2450,8 +2503,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleIdePromptComplete,
|
||||
handleFolderTrustSelect,
|
||||
setIsPolicyUpdateDialogOpen,
|
||||
setConstrainHeight,
|
||||
onEscapePromptChange: handleEscapePromptChange,
|
||||
handleSetConstrainHeight,
|
||||
setConstrainHeight: handleSetConstrainHeight,
|
||||
refreshStatic,
|
||||
handleFinalSubmit,
|
||||
handleClearScreen,
|
||||
@@ -2464,7 +2517,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
closeSessionBrowser,
|
||||
handleResumeSession,
|
||||
handleDeleteSession,
|
||||
setQueueErrorMessage,
|
||||
setQueueErrorMessage: handleSetQueueErrorMessage,
|
||||
popAllMessages,
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
@@ -2542,8 +2595,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleIdePromptComplete,
|
||||
handleFolderTrustSelect,
|
||||
setIsPolicyUpdateDialogOpen,
|
||||
setConstrainHeight,
|
||||
handleEscapePromptChange,
|
||||
handleSetConstrainHeight,
|
||||
refreshStatic,
|
||||
handleFinalSubmit,
|
||||
handleClearScreen,
|
||||
@@ -2555,7 +2607,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
closeSessionBrowser,
|
||||
handleResumeSession,
|
||||
handleDeleteSession,
|
||||
setQueueErrorMessage,
|
||||
handleSetQueueErrorMessage,
|
||||
popAllMessages,
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
@@ -2601,8 +2653,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}}
|
||||
>
|
||||
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<App key={`app-${forceRerenderKey}`} />
|
||||
<ShellFocusContext.Provider value={embeddedShellFocused}>
|
||||
<Box ref={rootUiRef} flexDirection="column">
|
||||
<App key={`app-${forceRerenderKey}`} />
|
||||
</Box>
|
||||
</ShellFocusContext.Provider>
|
||||
</ToolActionsProvider>
|
||||
</AppContext.Provider>
|
||||
|
||||
@@ -105,34 +105,40 @@ describe('agentsCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should reload the agent registry when refresh subcommand is called', async () => {
|
||||
it('should reload the agent registry when reload subcommand is called', async () => {
|
||||
const reloadSpy = vi.fn().mockResolvedValue(undefined);
|
||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
||||
reload: reloadSpy,
|
||||
});
|
||||
|
||||
const refreshCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'refresh',
|
||||
const reloadCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'reload',
|
||||
);
|
||||
expect(refreshCommand).toBeDefined();
|
||||
expect(reloadCommand).toBeDefined();
|
||||
|
||||
const result = await refreshCommand!.action!(mockContext, '');
|
||||
const result = await reloadCommand!.action!(mockContext, '');
|
||||
|
||||
expect(reloadSpy).toHaveBeenCalled();
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: 'Reloading agent registry...',
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Agents refreshed successfully.',
|
||||
content: 'Agents reloaded successfully',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show an error if agent registry is not available during refresh', async () => {
|
||||
it('should show an error if agent registry is not available during reload', async () => {
|
||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue(undefined);
|
||||
|
||||
const refreshCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'refresh',
|
||||
const reloadCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'reload',
|
||||
);
|
||||
const result = await refreshCommand!.action!(mockContext, '');
|
||||
const result = await reloadCommand!.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
|
||||
@@ -322,9 +322,9 @@ const configCommand: SlashCommand = {
|
||||
completion: completeAllAgents,
|
||||
};
|
||||
|
||||
const agentsRefreshCommand: SlashCommand = {
|
||||
name: 'refresh',
|
||||
altNames: ['reload'],
|
||||
const agentsReloadCommand: SlashCommand = {
|
||||
name: 'reload',
|
||||
altNames: ['refresh'],
|
||||
description: 'Reload the agent registry',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext) => {
|
||||
@@ -340,7 +340,7 @@ const agentsRefreshCommand: SlashCommand = {
|
||||
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: 'Refreshing agent registry...',
|
||||
text: 'Reloading agent registry...',
|
||||
});
|
||||
|
||||
await agentRegistry.reload();
|
||||
@@ -348,7 +348,7 @@ const agentsRefreshCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Agents refreshed successfully.',
|
||||
content: 'Agents reloaded successfully',
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -359,7 +359,7 @@ export const agentsCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [
|
||||
agentsListCommand,
|
||||
agentsRefreshCommand,
|
||||
agentsReloadCommand,
|
||||
enableCommand,
|
||||
disableCommand,
|
||||
configCommand,
|
||||
|
||||
@@ -892,7 +892,7 @@ describe('extensionsCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('restart', () => {
|
||||
describe('reload', () => {
|
||||
let restartAction: SlashCommand['action'];
|
||||
let mockRestartExtension: MockedFunction<
|
||||
typeof ExtensionLoader.prototype.restartExtension
|
||||
@@ -900,7 +900,7 @@ describe('extensionsCommand', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
restartAction = extensionsCommand().subCommands?.find(
|
||||
(c) => c.name === 'restart',
|
||||
(c) => c.name === 'reload',
|
||||
)?.action;
|
||||
expect(restartAction).not.toBeNull();
|
||||
|
||||
@@ -911,7 +911,7 @@ describe('extensionsCommand', () => {
|
||||
getExtensions: mockGetExtensions,
|
||||
restartExtension: mockRestartExtension,
|
||||
}));
|
||||
mockContext.invocation!.name = 'restart';
|
||||
mockContext.invocation!.name = 'reload';
|
||||
});
|
||||
|
||||
it('should show a message if no extensions are installed', async () => {
|
||||
@@ -930,7 +930,7 @@ describe('extensionsCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('restarts all active extensions when --all is provided', async () => {
|
||||
it('reloads all active extensions when --all is provided', async () => {
|
||||
const mockExtensions = [
|
||||
{ name: 'ext1', isActive: true },
|
||||
{ name: 'ext2', isActive: true },
|
||||
@@ -946,13 +946,13 @@ describe('extensionsCommand', () => {
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: 'Restarting 2 extensions...',
|
||||
text: 'Reloading 2 extensions...',
|
||||
}),
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: '2 extensions restarted successfully.',
|
||||
text: '2 extensions reloaded successfully',
|
||||
}),
|
||||
);
|
||||
expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({
|
||||
@@ -986,7 +986,7 @@ describe('extensionsCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('restarts only specified active extensions', async () => {
|
||||
it('reloads only specified active extensions', async () => {
|
||||
const mockExtensions = [
|
||||
{ name: 'ext1', isActive: false },
|
||||
{ name: 'ext2', isActive: true },
|
||||
@@ -1024,13 +1024,13 @@ describe('extensionsCommand', () => {
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /extensions restart <extension-names>|--all',
|
||||
text: 'Usage: /extensions reload <extension-names>|--all',
|
||||
}),
|
||||
);
|
||||
expect(mockRestartExtension).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles errors during extension restart', async () => {
|
||||
it('handles errors during extension reload', async () => {
|
||||
const mockExtensions = [
|
||||
{ name: 'ext1', isActive: true },
|
||||
] as GeminiCLIExtension[];
|
||||
@@ -1043,7 +1043,7 @@ describe('extensionsCommand', () => {
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Failed to restart some extensions:\n ext1: Failed to restart',
|
||||
text: 'Failed to reload some extensions:\n ext1: Failed to restart',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1066,7 +1066,7 @@ describe('extensionsCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not restart any extensions if none are found', async () => {
|
||||
it('does not reload any extensions if none are found', async () => {
|
||||
const mockExtensions = [
|
||||
{ name: 'ext1', isActive: true },
|
||||
] as GeminiCLIExtension[];
|
||||
@@ -1083,8 +1083,8 @@ describe('extensionsCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should suggest only enabled extension names for the restart command', async () => {
|
||||
mockContext.invocation!.name = 'restart';
|
||||
it('should suggest only enabled extension names for the reload command', async () => {
|
||||
mockContext.invocation!.name = 'reload';
|
||||
const mockExtensions = [
|
||||
{ name: 'ext1', isActive: true },
|
||||
{ name: 'ext2', isActive: false },
|
||||
|
||||
@@ -176,7 +176,7 @@ async function restartAction(
|
||||
if (!all && names?.length === 0) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /extensions restart <extension-names>|--all',
|
||||
text: 'Usage: /extensions reload <extension-names>|--all',
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -208,12 +208,12 @@ async function restartAction(
|
||||
|
||||
const s = extensionsToRestart.length > 1 ? 's' : '';
|
||||
|
||||
const restartingMessage = {
|
||||
const reloadingMessage = {
|
||||
type: MessageType.INFO,
|
||||
text: `Restarting ${extensionsToRestart.length} extension${s}...`,
|
||||
text: `Reloading ${extensionsToRestart.length} extension${s}...`,
|
||||
color: theme.text.primary,
|
||||
};
|
||||
context.ui.addItem(restartingMessage);
|
||||
context.ui.addItem(reloadingMessage);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
extensionsToRestart.map(async (extension) => {
|
||||
@@ -254,12 +254,12 @@ async function restartAction(
|
||||
.join('\n ');
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to restart some extensions:\n ${errorMessages}`,
|
||||
text: `Failed to reload some extensions:\n ${errorMessages}`,
|
||||
});
|
||||
} else {
|
||||
const infoItem: HistoryItemInfo = {
|
||||
type: MessageType.INFO,
|
||||
text: `${extensionsToRestart.length} extension${s} restarted successfully.`,
|
||||
text: `${extensionsToRestart.length} extension${s} reloaded successfully`,
|
||||
icon: emptyIcon,
|
||||
color: theme.text.primary,
|
||||
};
|
||||
@@ -729,7 +729,8 @@ export function completeExtensions(
|
||||
}
|
||||
if (
|
||||
context.invocation?.name === 'disable' ||
|
||||
context.invocation?.name === 'restart'
|
||||
context.invocation?.name === 'restart' ||
|
||||
context.invocation?.name === 'reload'
|
||||
) {
|
||||
extensions = extensions.filter((ext) => ext.isActive);
|
||||
}
|
||||
@@ -824,9 +825,10 @@ const exploreExtensionsCommand: SlashCommand = {
|
||||
action: exploreAction,
|
||||
};
|
||||
|
||||
const restartCommand: SlashCommand = {
|
||||
name: 'restart',
|
||||
description: 'Restart all extensions',
|
||||
const reloadCommand: SlashCommand = {
|
||||
name: 'reload',
|
||||
altNames: ['restart'],
|
||||
description: 'Reload all extensions',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
action: restartAction,
|
||||
@@ -863,7 +865,7 @@ export function extensionsCommand(
|
||||
listExtensionsCommand,
|
||||
updateExtensionsCommand,
|
||||
exploreExtensionsCommand,
|
||||
restartCommand,
|
||||
reloadCommand,
|
||||
...conditionalCommands,
|
||||
],
|
||||
action: (context, args) =>
|
||||
|
||||
@@ -149,7 +149,7 @@ const authCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Successfully authenticated and refreshed tools for '${serverName}'.`,
|
||||
content: `Successfully authenticated and reloaded tools for '${serverName}'`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -325,10 +325,10 @@ const schemaCommand: SlashCommand = {
|
||||
action: (context) => listAction(context, true, true),
|
||||
};
|
||||
|
||||
const refreshCommand: SlashCommand = {
|
||||
name: 'refresh',
|
||||
altNames: ['reload'],
|
||||
description: 'Restarts MCP servers',
|
||||
const reloadCommand: SlashCommand = {
|
||||
name: 'reload',
|
||||
altNames: ['refresh'],
|
||||
description: 'Reloads MCP servers',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (
|
||||
@@ -354,7 +354,7 @@ const refreshCommand: SlashCommand = {
|
||||
|
||||
context.ui.addItem({
|
||||
type: 'info',
|
||||
text: 'Restarting MCP servers...',
|
||||
text: 'Reloading MCP servers...',
|
||||
});
|
||||
|
||||
await mcpClientManager.restart();
|
||||
@@ -460,7 +460,7 @@ async function handleEnableDisable(
|
||||
const mcpClientManager = config.getMcpClientManager();
|
||||
if (mcpClientManager) {
|
||||
context.ui.addItem(
|
||||
{ type: 'info', text: 'Restarting MCP servers...' },
|
||||
{ type: 'info', text: 'Reloading MCP servers...' },
|
||||
Date.now(),
|
||||
);
|
||||
await mcpClientManager.restart();
|
||||
@@ -521,7 +521,7 @@ export const mcpCommand: SlashCommand = {
|
||||
descCommand,
|
||||
schemaCommand,
|
||||
authCommand,
|
||||
refreshCommand,
|
||||
reloadCommand,
|
||||
enableCommand,
|
||||
disableCommand,
|
||||
],
|
||||
|
||||
@@ -39,13 +39,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`,
|
||||
content: `Memory reloaded successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Memory refreshed successfully.',
|
||||
content: 'Memory reloaded successfully.',
|
||||
};
|
||||
}),
|
||||
showMemory: vi.fn(),
|
||||
@@ -63,7 +63,7 @@ describe('memoryCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
const getSubCommand = (
|
||||
name: 'show' | 'add' | 'refresh' | 'list',
|
||||
name: 'show' | 'add' | 'reload' | 'list',
|
||||
): SlashCommand => {
|
||||
const subCommand = memoryCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === name,
|
||||
@@ -206,15 +206,15 @@ describe('memoryCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('/memory refresh', () => {
|
||||
let refreshCommand: SlashCommand;
|
||||
describe('/memory reload', () => {
|
||||
let reloadCommand: SlashCommand;
|
||||
let mockSetUserMemory: Mock;
|
||||
let mockSetGeminiMdFileCount: Mock;
|
||||
let mockSetGeminiMdFilePaths: Mock;
|
||||
let mockContextManagerRefresh: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
refreshCommand = getSubCommand('refresh');
|
||||
reloadCommand = getSubCommand('reload');
|
||||
mockSetUserMemory = vi.fn();
|
||||
mockSetGeminiMdFileCount = vi.fn();
|
||||
mockSetGeminiMdFilePaths = vi.fn();
|
||||
@@ -266,7 +266,7 @@ describe('memoryCommand', () => {
|
||||
});
|
||||
|
||||
it('should use ContextManager.refresh when JIT is enabled', async () => {
|
||||
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||
if (!reloadCommand.action) throw new Error('Command has no action');
|
||||
|
||||
// Enable JIT in mock config
|
||||
const config = mockContext.services.config;
|
||||
@@ -276,7 +276,7 @@ describe('memoryCommand', () => {
|
||||
vi.mocked(config.getUserMemory).mockReturnValue('JIT Memory Content');
|
||||
vi.mocked(config.getGeminiMdFileCount).mockReturnValue(3);
|
||||
|
||||
await refreshCommand.action(mockContext, '');
|
||||
await reloadCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContextManagerRefresh).toHaveBeenCalledOnce();
|
||||
expect(mockRefreshServerHierarchicalMemory).not.toHaveBeenCalled();
|
||||
@@ -284,29 +284,29 @@ describe('memoryCommand', () => {
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Memory refreshed successfully. Loaded 18 characters from 3 file(s).',
|
||||
text: 'Memory reloaded successfully. Loaded 18 characters from 3 file(s).',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display success message when memory is refreshed with content (Legacy)', async () => {
|
||||
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||
it('should display success message when memory is reloaded with content (Legacy)', async () => {
|
||||
if (!reloadCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const successMessage = {
|
||||
type: 'message',
|
||||
messageType: MessageType.INFO,
|
||||
content:
|
||||
'Memory refreshed successfully. Loaded 18 characters from 2 file(s).',
|
||||
'Memory reloaded successfully. Loaded 18 characters from 2 file(s).',
|
||||
};
|
||||
mockRefreshMemory.mockResolvedValue(successMessage);
|
||||
|
||||
await refreshCommand.action(mockContext, '');
|
||||
await reloadCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Refreshing memory from source files...',
|
||||
text: 'Reloading memory from source files...',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
@@ -316,42 +316,42 @@ describe('memoryCommand', () => {
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Memory refreshed successfully. Loaded 18 characters from 2 file(s).',
|
||||
text: 'Memory reloaded successfully. Loaded 18 characters from 2 file(s).',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display success message when memory is refreshed with no content', async () => {
|
||||
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||
it('should display success message when memory is reloaded with no content', async () => {
|
||||
if (!reloadCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const successMessage = {
|
||||
type: 'message',
|
||||
messageType: MessageType.INFO,
|
||||
content: 'Memory refreshed successfully. No memory content found.',
|
||||
content: 'Memory reloaded successfully. No memory content found.',
|
||||
};
|
||||
mockRefreshMemory.mockResolvedValue(successMessage);
|
||||
|
||||
await refreshCommand.action(mockContext, '');
|
||||
await reloadCommand.action(mockContext, '');
|
||||
|
||||
expect(mockRefreshMemory).toHaveBeenCalledOnce();
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Memory refreshed successfully. No memory content found.',
|
||||
text: 'Memory reloaded successfully. No memory content found.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display an error message if refreshing fails', async () => {
|
||||
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||
it('should display an error message if reloading fails', async () => {
|
||||
if (!reloadCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const error = new Error('Failed to read memory files.');
|
||||
mockRefreshMemory.mockRejectedValue(error);
|
||||
|
||||
await refreshCommand.action(mockContext, '');
|
||||
await reloadCommand.action(mockContext, '');
|
||||
|
||||
expect(mockRefreshMemory).toHaveBeenCalledOnce();
|
||||
expect(mockSetUserMemory).not.toHaveBeenCalled();
|
||||
@@ -361,27 +361,27 @@ describe('memoryCommand', () => {
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Error refreshing memory: ${error.message}`,
|
||||
text: `Error reloading memory: ${error.message}`,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw if config service is unavailable', async () => {
|
||||
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||
if (!reloadCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const nullConfigContext = createMockCommandContext({
|
||||
services: { config: null },
|
||||
});
|
||||
|
||||
await expect(
|
||||
refreshCommand.action(nullConfigContext, ''),
|
||||
reloadCommand.action(nullConfigContext, ''),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(nullConfigContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Refreshing memory from source files...',
|
||||
text: 'Reloading memory from source files...',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
@@ -63,16 +63,16 @@ export const memoryCommand: SlashCommand = {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'refresh',
|
||||
altNames: ['reload'],
|
||||
description: 'Refresh the memory from the source',
|
||||
name: 'reload',
|
||||
altNames: ['refresh'],
|
||||
description: 'Reload the memory from the source',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Refreshing memory from source files...',
|
||||
text: 'Reloading memory from source files...',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -95,7 +95,7 @@ export const memoryCommand: SlashCommand = {
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
text: `Error refreshing memory: ${(error as Error).message}`,
|
||||
text: `Error reloading memory: ${(error as Error).message}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
UserAccountManager: vi.fn().mockImplementation(() => ({
|
||||
getCachedGoogleAccount: vi.fn().mockReturnValue('mock@example.com'),
|
||||
})),
|
||||
getG1CreditBalance: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -63,13 +63,7 @@ vi.mock('./StatusDisplay.js', () => ({
|
||||
|
||||
vi.mock('./ToastDisplay.js', () => ({
|
||||
ToastDisplay: () => <Text>ToastDisplay</Text>,
|
||||
shouldShowToast: (uiState: UIState) =>
|
||||
uiState.ctrlCPressedOnce ||
|
||||
Boolean(uiState.transientMessage) ||
|
||||
uiState.ctrlDPressedOnce ||
|
||||
(uiState.showEscapePrompt &&
|
||||
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
|
||||
Boolean(uiState.queueErrorMessage),
|
||||
shouldShowToast: (uiState: UIState) => Boolean(uiState.transientMessage),
|
||||
}));
|
||||
|
||||
vi.mock('./ContextSummaryDisplay.js', () => ({
|
||||
@@ -175,9 +169,6 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
thought: '',
|
||||
currentLoadingPhrase: '',
|
||||
elapsedTime: 0,
|
||||
ctrlCPressedOnce: false,
|
||||
ctrlDPressedOnce: false,
|
||||
showEscapePrompt: false,
|
||||
shortcutsHelpVisible: false,
|
||||
cleanUiDetailsVisible: true,
|
||||
ideContextState: null,
|
||||
@@ -539,10 +530,7 @@ describe('Composer', () => {
|
||||
describe('Context and Status Display', () => {
|
||||
it('shows StatusDisplay and ApprovalModeIndicator in normal state', async () => {
|
||||
const uiState = createMockUIState({
|
||||
ctrlCPressedOnce: false,
|
||||
ctrlDPressedOnce: false,
|
||||
showEscapePrompt: false,
|
||||
});
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
@@ -554,7 +542,7 @@ describe('Composer', () => {
|
||||
|
||||
it('shows ToastDisplay and hides ApprovalModeIndicator when a toast is present', async () => {
|
||||
const uiState = createMockUIState({
|
||||
ctrlCPressedOnce: true,
|
||||
transientMessage: { message: 'test', type: TransientMessageType.Warning },
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
@@ -567,10 +555,7 @@ describe('Composer', () => {
|
||||
|
||||
it('shows ToastDisplay for other toast types', async () => {
|
||||
const uiState = createMockUIState({
|
||||
transientMessage: {
|
||||
text: 'Warning',
|
||||
type: TransientMessageType.Warning,
|
||||
},
|
||||
transientMessage: { message: 'Warning', type: TransientMessageType.Warning },
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
@@ -646,26 +631,6 @@ describe('Composer', () => {
|
||||
expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/);
|
||||
});
|
||||
|
||||
it('shows RawMarkdownIndicator when renderMarkdown is false', async () => {
|
||||
const uiState = createMockUIState({
|
||||
renderMarkdown: false,
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('raw markdown mode');
|
||||
});
|
||||
|
||||
it('does not show RawMarkdownIndicator when renderMarkdown is true', async () => {
|
||||
const uiState = createMockUIState({
|
||||
renderMarkdown: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('raw markdown mode');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ApprovalMode.YOLO, 'YOLO'],
|
||||
[ApprovalMode.PLAN, 'plan'],
|
||||
@@ -715,18 +680,7 @@ describe('Composer', () => {
|
||||
expect(output).not.toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
it('shows Esc rewind prompt in minimal mode without showing full UI', async () => {
|
||||
const uiState = createMockUIState({
|
||||
cleanUiDetailsVisible: false,
|
||||
showEscapePrompt: true,
|
||||
history: [{ id: 1, type: 'user', text: 'msg' }],
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('ToastDisplay');
|
||||
expect(output).not.toContain('ContextSummaryDisplay');
|
||||
});
|
||||
|
||||
it('shows context usage bleed-through when over 60%', async () => {
|
||||
const model = 'gemini-2.5-pro';
|
||||
|
||||
@@ -17,7 +17,6 @@ import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
|
||||
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
|
||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
||||
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
|
||||
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
|
||||
import { ShortcutsHint } from './ShortcutsHint.js';
|
||||
import { ShortcutsHelp } from './ShortcutsHelp.js';
|
||||
import { InputPrompt } from './InputPrompt.js';
|
||||
@@ -114,7 +113,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
suggestionsVisible && suggestionsPosition === 'above';
|
||||
const showApprovalIndicator =
|
||||
!uiState.shellModeActive && !hideUiDetailsForSuggestions;
|
||||
const showRawMarkdownIndicator = !uiState.renderMarkdown;
|
||||
let modeBleedThrough: { text: string; color: string } | null = null;
|
||||
switch (showApprovalModeIndicator) {
|
||||
case ApprovalMode.YOLO:
|
||||
@@ -378,26 +376,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
<ShellModeIndicator />
|
||||
</Box>
|
||||
)}
|
||||
{showRawMarkdownIndicator && (
|
||||
<Box
|
||||
marginLeft={
|
||||
(showApprovalIndicator ||
|
||||
uiState.shellModeActive) &&
|
||||
!isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
marginTop={
|
||||
(showApprovalIndicator ||
|
||||
uiState.shellModeActive) &&
|
||||
!isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<RawMarkdownIndicator />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
@@ -448,7 +426,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
shellModeActive={uiState.shellModeActive}
|
||||
setShellModeActive={uiActions.setShellModeActive}
|
||||
approvalMode={showApprovalModeIndicator}
|
||||
onEscapePromptChange={uiActions.onEscapePromptChange}
|
||||
focus={isFocused}
|
||||
vimHandleInput={uiActions.vimHandleInput}
|
||||
isEmbeddedShellFocused={uiState.embeddedShellFocused}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { render } from '../../test-utils/render.js';
|
||||
import { ExitWarning } from './ExitWarning.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useUIState, type UIState } from '../contexts/UIStateContext.js';
|
||||
import { TransientMessageType } from '../../utils/events.js';
|
||||
|
||||
vi.mock('../contexts/UIStateContext.js');
|
||||
|
||||
@@ -21,8 +22,7 @@ describe('ExitWarning', () => {
|
||||
it('renders nothing by default', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
dialogsVisible: false,
|
||||
ctrlCPressedOnce: false,
|
||||
ctrlDPressedOnce: false,
|
||||
transientMessage: null,
|
||||
} as unknown as UIState);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
|
||||
await waitUntilReady();
|
||||
@@ -30,35 +30,41 @@ describe('ExitWarning', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders Ctrl+C warning when pressed once and dialogs visible', async () => {
|
||||
it('renders warning when transient message is a warning and dialogs visible', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
dialogsVisible: true,
|
||||
ctrlCPressedOnce: true,
|
||||
ctrlDPressedOnce: false,
|
||||
transientMessage: {
|
||||
message: 'Test Warning',
|
||||
type: TransientMessageType.Warning,
|
||||
},
|
||||
} as unknown as UIState);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Press Ctrl+C again to exit');
|
||||
expect(lastFrame()).toContain('Test Warning');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders Ctrl+D warning when pressed once and dialogs visible', async () => {
|
||||
it('renders nothing if transient message is not a warning', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
dialogsVisible: true,
|
||||
ctrlCPressedOnce: false,
|
||||
ctrlDPressedOnce: true,
|
||||
transientMessage: {
|
||||
message: 'Test Hint',
|
||||
type: TransientMessageType.Hint,
|
||||
},
|
||||
} as unknown as UIState);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Press Ctrl+D again to exit');
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders nothing if dialogs are not visible', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
dialogsVisible: false,
|
||||
ctrlCPressedOnce: true,
|
||||
ctrlDPressedOnce: true,
|
||||
transientMessage: {
|
||||
message: 'Test Warning',
|
||||
type: TransientMessageType.Warning,
|
||||
},
|
||||
} as unknown as UIState);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
|
||||
await waitUntilReady();
|
||||
|
||||
@@ -8,22 +8,24 @@ import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { TransientMessageType } from '../../utils/events.js';
|
||||
|
||||
export const ExitWarning: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
return (
|
||||
<>
|
||||
{uiState.dialogsVisible && uiState.ctrlCPressedOnce && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text>
|
||||
</Box>
|
||||
)}
|
||||
if (!uiState.dialogsVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
{uiState.dialogsVisible && uiState.ctrlDPressedOnce && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.warning}>Press Ctrl+D again to exit.</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
if (
|
||||
uiState.transientMessage?.type === TransientMessageType.Warning &&
|
||||
uiState.transientMessage.message
|
||||
) {
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.warning}>{uiState.transientMessage.message}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -311,9 +311,5 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
|
||||
</Box>
|
||||
);
|
||||
|
||||
return isAlternateBuffer ? (
|
||||
<OverflowProvider>{content}</OverflowProvider>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
return <OverflowProvider>{content}</OverflowProvider>;
|
||||
};
|
||||
|
||||
@@ -235,7 +235,7 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('15%');
|
||||
expect(lastFrame()).toContain('85%');
|
||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -5,10 +5,20 @@
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { QuotaDisplay } from './QuotaDisplay.js';
|
||||
|
||||
describe('QuotaDisplay', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('TZ', 'America/Los_Angeles');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-03-02T20:29:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
it('should not render when remaining is undefined', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<QuotaDisplay remaining={undefined} limit={100} />,
|
||||
@@ -36,7 +46,7 @@ describe('QuotaDisplay', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not render when usage > 20%', async () => {
|
||||
it('should not render when usage < 80%', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<QuotaDisplay remaining={85} limit={100} />,
|
||||
);
|
||||
@@ -45,7 +55,7 @@ describe('QuotaDisplay', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render yellow when usage < 20%', async () => {
|
||||
it('should render warning when used >= 80%', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<QuotaDisplay remaining={15} limit={100} />,
|
||||
);
|
||||
@@ -54,7 +64,7 @@ describe('QuotaDisplay', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render red when usage < 5%', async () => {
|
||||
it('should render critical when used >= 95%', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<QuotaDisplay remaining={4} limit={100} />,
|
||||
);
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
import type React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import {
|
||||
getStatusColor,
|
||||
QUOTA_THRESHOLD_HIGH,
|
||||
QUOTA_THRESHOLD_MEDIUM,
|
||||
getUsedStatusColor,
|
||||
QUOTA_USED_WARNING_THRESHOLD,
|
||||
QUOTA_USED_CRITICAL_THRESHOLD,
|
||||
} from '../utils/displayUtils.js';
|
||||
import { formatResetTime } from '../utils/formatters.js';
|
||||
|
||||
@@ -34,32 +34,36 @@ export const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const percentage = (remaining / limit) * 100;
|
||||
const usedPercentage = 100 - (remaining / limit) * 100;
|
||||
|
||||
if (!forceShow && percentage > QUOTA_THRESHOLD_HIGH) {
|
||||
if (!forceShow && usedPercentage < QUOTA_USED_WARNING_THRESHOLD) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const color = getStatusColor(percentage, {
|
||||
green: QUOTA_THRESHOLD_HIGH,
|
||||
yellow: QUOTA_THRESHOLD_MEDIUM,
|
||||
const color = getUsedStatusColor(usedPercentage, {
|
||||
warning: QUOTA_USED_WARNING_THRESHOLD,
|
||||
critical: QUOTA_USED_CRITICAL_THRESHOLD,
|
||||
});
|
||||
|
||||
const resetInfo =
|
||||
!terse && resetTime ? `, ${formatResetTime(resetTime)}` : '';
|
||||
|
||||
let text: string;
|
||||
if (remaining === 0) {
|
||||
let text = terse
|
||||
? 'Limit reached'
|
||||
: `/stats Limit reached${resetInfo}${!terse && '. /auth to continue.'}`;
|
||||
if (lowercase) text = text.toLowerCase();
|
||||
return <Text color={color}>{text}</Text>;
|
||||
const resetMsg = resetTime
|
||||
? `, resets in ${formatResetTime(resetTime, 'terse')}`
|
||||
: '';
|
||||
text = terse ? 'Limit reached' : `Limit reached${resetMsg}`;
|
||||
} else {
|
||||
text = terse
|
||||
? `${usedPercentage.toFixed(0)}%`
|
||||
: `${usedPercentage.toFixed(0)}% used${
|
||||
resetTime
|
||||
? ` (Limit resets in ${formatResetTime(resetTime, 'terse')})`
|
||||
: ''
|
||||
}`;
|
||||
}
|
||||
|
||||
let text = terse
|
||||
? `${percentage.toFixed(0)}%`
|
||||
: `/stats ${percentage.toFixed(0)}% usage remaining${resetInfo}`;
|
||||
if (lowercase) text = text.toLowerCase();
|
||||
if (lowercase) {
|
||||
text = text.toLowerCase();
|
||||
}
|
||||
|
||||
return <Text color={color}>{text}</Text>;
|
||||
};
|
||||
|
||||
@@ -9,9 +9,9 @@ import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { formatResetTime } from '../utils/formatters.js';
|
||||
import {
|
||||
getStatusColor,
|
||||
QUOTA_THRESHOLD_HIGH,
|
||||
QUOTA_THRESHOLD_MEDIUM,
|
||||
getUsedStatusColor,
|
||||
QUOTA_USED_WARNING_THRESHOLD,
|
||||
QUOTA_USED_CRITICAL_THRESHOLD,
|
||||
} from '../utils/displayUtils.js';
|
||||
|
||||
interface QuotaStatsInfoProps {
|
||||
@@ -31,19 +31,26 @@ export const QuotaStatsInfo: React.FC<QuotaStatsInfoProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const percentage = (remaining / limit) * 100;
|
||||
const color = getStatusColor(percentage, {
|
||||
green: QUOTA_THRESHOLD_HIGH,
|
||||
yellow: QUOTA_THRESHOLD_MEDIUM,
|
||||
const usedPercentage = 100 - (remaining / limit) * 100;
|
||||
const color = getUsedStatusColor(usedPercentage, {
|
||||
warning: QUOTA_USED_WARNING_THRESHOLD,
|
||||
critical: QUOTA_USED_CRITICAL_THRESHOLD,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={0} marginBottom={0}>
|
||||
<Text color={color}>
|
||||
{remaining === 0
|
||||
? `Limit reached`
|
||||
: `${percentage.toFixed(0)}% usage remaining`}
|
||||
{resetTime && `, ${formatResetTime(resetTime)}`}
|
||||
? `Limit reached${
|
||||
resetTime
|
||||
? `, resets in ${formatResetTime(resetTime, 'terse')}`
|
||||
: ''
|
||||
}`
|
||||
: `${usedPercentage.toFixed(0)}% used${
|
||||
resetTime
|
||||
? ` (Limit resets in ${formatResetTime(resetTime, 'terse')})`
|
||||
: ''
|
||||
}`}
|
||||
</Text>
|
||||
{showDetails && (
|
||||
<>
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
|
||||
|
||||
describe('RawMarkdownIndicator', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => vi.stubEnv('FORCE_GENERIC_KEYBINDING_HINTS', ''));
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('renders correct key binding for darwin', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
});
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<RawMarkdownIndicator />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('raw markdown mode');
|
||||
expect(lastFrame()).toContain('Option+M to toggle');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders correct key binding for other platforms', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
});
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<RawMarkdownIndicator />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('raw markdown mode');
|
||||
expect(lastFrame()).toContain('Alt+M to toggle');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { formatCommand } from '../utils/keybindingUtils.js';
|
||||
import { Command } from '../../config/keyBindings.js';
|
||||
|
||||
export const RawMarkdownIndicator: React.FC = () => {
|
||||
const modKey = formatCommand(Command.TOGGLE_MARKDOWN);
|
||||
return (
|
||||
<Box>
|
||||
<Text>
|
||||
raw markdown mode
|
||||
<Text color={theme.text.secondary}> ({modKey} to toggle) </Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -45,7 +45,7 @@ describe('ShowMoreLines', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('renders nothing in STANDARD mode even if overflowing', async () => {
|
||||
it('renders message in STANDARD mode when overflowing', async () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(false);
|
||||
mockUseOverflowState.mockReturnValue({
|
||||
overflowingIds: new Set(['1']),
|
||||
@@ -55,7 +55,9 @@ describe('ShowMoreLines', () => {
|
||||
<ShowMoreLines constrainHeight={true} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
expect(lastFrame().toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ 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;
|
||||
@@ -20,7 +19,6 @@ export const ShowMoreLines = ({
|
||||
constrainHeight,
|
||||
isOverflowing: isOverflowingProp,
|
||||
}: ShowMoreLinesProps) => {
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const overflowState = useOverflowState();
|
||||
const streamingState = useStreamingContext();
|
||||
|
||||
@@ -29,7 +27,6 @@ export const ShowMoreLines = ({
|
||||
(overflowState !== undefined && overflowState.overflowingIds.size > 0);
|
||||
|
||||
if (
|
||||
!isAlternateBuffer ||
|
||||
!isOverflowing ||
|
||||
!constrainHeight ||
|
||||
!(
|
||||
|
||||
@@ -64,4 +64,32 @@ describe('ShowMoreLines layout and padding', () => {
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders in Standard mode as well', async () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(false); // Standard mode
|
||||
|
||||
const TestComponent = () => (
|
||||
<Box flexDirection="column">
|
||||
<Text>Top</Text>
|
||||
<ShowMoreLines constrainHeight={true} />
|
||||
<Text>Bottom</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = render(<TestComponent />);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame({ allowEmpty: true });
|
||||
const lines = output.split('\n');
|
||||
|
||||
expect(lines).toEqual([
|
||||
'Top',
|
||||
' Press Ctrl+O to show more lines',
|
||||
'',
|
||||
'Bottom',
|
||||
'',
|
||||
]);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,14 @@ const createTestMetrics = (
|
||||
});
|
||||
|
||||
describe('<StatsDisplay />', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('TZ', 'UTC');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('renders only the Performance section in its zero state', async () => {
|
||||
const zeroMetrics = createTestMetrics();
|
||||
|
||||
@@ -465,9 +473,9 @@ describe('<StatsDisplay />', () => {
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Usage remaining');
|
||||
expect(output).toContain('75.0%');
|
||||
expect(output).toContain('resets in 1h 30m');
|
||||
expect(output).toContain('Model usage');
|
||||
expect(output).toContain('25%');
|
||||
expect(output).toContain('Usage resets');
|
||||
expect(output).toMatchSnapshot();
|
||||
|
||||
vi.useRealTimers();
|
||||
@@ -521,8 +529,8 @@ describe('<StatsDisplay />', () => {
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
|
||||
// (10 + 700) / (100 + 1000) = 710 / 1100 = 64.5%
|
||||
expect(output).toContain('65% usage remaining');
|
||||
// (1 - 710/1100) * 100 = 35.5%
|
||||
expect(output).toContain('35%');
|
||||
expect(output).toContain('Usage limit: 1,100');
|
||||
expect(output).toMatchSnapshot();
|
||||
|
||||
@@ -571,8 +579,8 @@ describe('<StatsDisplay />', () => {
|
||||
|
||||
expect(output).toContain('gemini-2.5-flash');
|
||||
expect(output).toContain('-'); // for requests
|
||||
expect(output).toContain('50.0%');
|
||||
expect(output).toContain('resets in 2h');
|
||||
expect(output).toContain('50%');
|
||||
expect(output).toContain('Usage resets');
|
||||
expect(output).toMatchSnapshot();
|
||||
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Box, Text, useStdout } from 'ink';
|
||||
import { ThemedGradient } from './ThemedGradient.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { formatDuration, formatResetTime } from '../utils/formatters.js';
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
USER_AGREEMENT_RATE_MEDIUM,
|
||||
CACHE_EFFICIENCY_HIGH,
|
||||
CACHE_EFFICIENCY_MEDIUM,
|
||||
getUsedStatusColor,
|
||||
QUOTA_USED_WARNING_THRESHOLD,
|
||||
QUOTA_USED_CRITICAL_THRESHOLD,
|
||||
} from '../utils/displayUtils.js';
|
||||
import { computeSessionStats } from '../utils/computeStats.js';
|
||||
import {
|
||||
@@ -155,6 +158,8 @@ const ModelUsageTable: React.FC<{
|
||||
useGemini3_1,
|
||||
useCustomToolModel,
|
||||
}) => {
|
||||
const { stdout } = useStdout();
|
||||
const terminalWidth = stdout?.columns ?? 84;
|
||||
const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel);
|
||||
|
||||
if (rows.length === 0) {
|
||||
@@ -163,12 +168,47 @@ const ModelUsageTable: React.FC<{
|
||||
|
||||
const showQuotaColumn = !!quotas && rows.some((row) => !!row.bucket);
|
||||
|
||||
const nameWidth = 25;
|
||||
const requestsWidth = 7;
|
||||
const nameWidth = 23;
|
||||
const requestsWidth = 5;
|
||||
const uncachedWidth = 15;
|
||||
const cachedWidth = 14;
|
||||
const outputTokensWidth = 15;
|
||||
const usageLimitWidth = showQuotaColumn ? 28 : 0;
|
||||
const percentageWidth = showQuotaColumn ? 6 : 0;
|
||||
const resetWidth = 22;
|
||||
|
||||
// Total width of other columns (including parent box paddingX={2})
|
||||
const fixedWidth = nameWidth + requestsWidth + percentageWidth + resetWidth;
|
||||
const outerPadding = 4;
|
||||
const availableForUsage = terminalWidth - outerPadding - fixedWidth;
|
||||
|
||||
const usageLimitWidth = showQuotaColumn
|
||||
? Math.max(10, Math.min(24, availableForUsage))
|
||||
: 0;
|
||||
const progressBarWidth = Math.max(2, usageLimitWidth - 4);
|
||||
|
||||
const renderProgressBar = (
|
||||
usedFraction: number,
|
||||
color: string,
|
||||
totalSteps = 20,
|
||||
) => {
|
||||
let filledSteps = Math.round(usedFraction * totalSteps);
|
||||
|
||||
// If something is used (fraction > 0) but rounds to 0, show 1 tick.
|
||||
// If < 100% (fraction < 1) but rounds to 20, show 19 ticks.
|
||||
if (usedFraction > 0 && usedFraction < 1) {
|
||||
filledSteps = Math.min(Math.max(filledSteps, 1), totalSteps - 1);
|
||||
}
|
||||
|
||||
const emptySteps = Math.max(0, totalSteps - filledSteps);
|
||||
return (
|
||||
<Box flexDirection="row" flexShrink={0}>
|
||||
<Text wrap="truncate-end">
|
||||
<Text color={color}>{'▬'.repeat(filledSteps)}</Text>
|
||||
<Text color={theme.border.default}>{'▬'.repeat(emptySteps)}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const cacheEfficiencyColor = getStatusColor(cacheEfficiency, {
|
||||
green: CACHE_EFFICIENCY_HIGH,
|
||||
@@ -179,25 +219,13 @@ const ModelUsageTable: React.FC<{
|
||||
nameWidth +
|
||||
requestsWidth +
|
||||
(showQuotaColumn
|
||||
? usageLimitWidth
|
||||
? usageLimitWidth + percentageWidth + resetWidth
|
||||
: uncachedWidth + cachedWidth + outputTokensWidth);
|
||||
|
||||
const isAuto = currentModel && isAutoModel(currentModel);
|
||||
const modelUsageTitle = isAuto
|
||||
? `${getDisplayString(currentModel)} Usage`
|
||||
: `Model Usage`;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{/* Header */}
|
||||
<Box alignItems="flex-end">
|
||||
<Box width={nameWidth}>
|
||||
<Text bold color={theme.text.primary} wrap="truncate-end">
|
||||
{modelUsageTitle}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isAuto &&
|
||||
showQuotaColumn &&
|
||||
pooledRemaining !== undefined &&
|
||||
@@ -216,7 +244,7 @@ const ModelUsageTable: React.FC<{
|
||||
)}
|
||||
|
||||
<Box alignItems="flex-end">
|
||||
<Box width={nameWidth}>
|
||||
<Box width={nameWidth} flexShrink={0}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Model
|
||||
</Text>
|
||||
@@ -267,15 +295,31 @@ const ModelUsageTable: React.FC<{
|
||||
</>
|
||||
)}
|
||||
{showQuotaColumn && (
|
||||
<Box
|
||||
width={usageLimitWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Usage remaining
|
||||
</Text>
|
||||
</Box>
|
||||
<>
|
||||
<Box
|
||||
width={usageLimitWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-start"
|
||||
paddingLeft={4}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Model usage
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={percentageWidth} flexShrink={0} />
|
||||
<Box
|
||||
width={resetWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-start"
|
||||
paddingLeft={2}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text bold color={theme.text.primary} wrap="truncate-end">
|
||||
Usage resets
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -290,84 +334,150 @@ const ModelUsageTable: React.FC<{
|
||||
width={totalWidth}
|
||||
></Box>
|
||||
|
||||
{rows.map((row) => (
|
||||
<Box key={row.key}>
|
||||
<Box width={nameWidth}>
|
||||
<Text
|
||||
color={row.isActive ? theme.text.primary : theme.text.secondary}
|
||||
wrap="truncate-end"
|
||||
{rows.map((row) => {
|
||||
let effectiveUsedFraction = 0;
|
||||
let usedPercentage = 0;
|
||||
let statusColor = theme.ui.comment;
|
||||
let percentageText = '';
|
||||
|
||||
if (row.bucket && row.bucket.remainingFraction != null) {
|
||||
const actualUsedFraction = 1 - row.bucket.remainingFraction;
|
||||
effectiveUsedFraction =
|
||||
actualUsedFraction === 0 && row.isActive
|
||||
? 0.001
|
||||
: actualUsedFraction;
|
||||
usedPercentage = effectiveUsedFraction * 100;
|
||||
statusColor =
|
||||
getUsedStatusColor(usedPercentage, {
|
||||
warning: QUOTA_USED_WARNING_THRESHOLD,
|
||||
critical: QUOTA_USED_CRITICAL_THRESHOLD,
|
||||
}) ?? (row.isActive ? theme.text.primary : theme.ui.comment);
|
||||
percentageText =
|
||||
usedPercentage > 0 && usedPercentage < 1
|
||||
? `${usedPercentage.toFixed(1)}%`
|
||||
: `${usedPercentage.toFixed(0)}%`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={row.key}>
|
||||
<Box width={nameWidth} flexShrink={0}>
|
||||
<Text
|
||||
color={row.isActive ? theme.text.primary : theme.text.secondary}
|
||||
wrap="truncate-end"
|
||||
>
|
||||
{row.modelName}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
width={requestsWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
{row.modelName}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
width={requestsWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text
|
||||
color={row.isActive ? theme.text.primary : theme.text.secondary}
|
||||
>
|
||||
{row.requests}
|
||||
</Text>
|
||||
</Box>
|
||||
{!showQuotaColumn && (
|
||||
<>
|
||||
<Box
|
||||
width={uncachedWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
<Text
|
||||
color={row.isActive ? theme.text.primary : theme.text.secondary}
|
||||
>
|
||||
<Text
|
||||
color={
|
||||
row.isActive ? theme.text.primary : theme.text.secondary
|
||||
}
|
||||
{row.requests}
|
||||
</Text>
|
||||
</Box>
|
||||
{!showQuotaColumn && (
|
||||
<>
|
||||
<Box
|
||||
width={uncachedWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
{row.inputTokens}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
width={cachedWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text color={theme.text.secondary}>{row.cachedTokens}</Text>
|
||||
</Box>
|
||||
<Box
|
||||
width={outputTokensWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text
|
||||
color={
|
||||
row.isActive ? theme.text.primary : theme.text.secondary
|
||||
}
|
||||
<Text
|
||||
color={
|
||||
row.isActive ? theme.text.primary : theme.text.secondary
|
||||
}
|
||||
>
|
||||
{row.inputTokens}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
width={cachedWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
{row.outputTokens}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<Box
|
||||
width={usageLimitWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
>
|
||||
{row.bucket &&
|
||||
row.bucket.remainingFraction != null &&
|
||||
row.bucket.resetTime && (
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{(row.bucket.remainingFraction * 100).toFixed(1)}%{' '}
|
||||
{formatResetTime(row.bucket.resetTime)}
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.text.secondary}>{row.cachedTokens}</Text>
|
||||
</Box>
|
||||
<Box
|
||||
width={outputTokensWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text
|
||||
color={
|
||||
row.isActive ? theme.text.primary : theme.text.secondary
|
||||
}
|
||||
>
|
||||
{row.outputTokens}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{showQuotaColumn && (
|
||||
<>
|
||||
<Box
|
||||
width={usageLimitWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-start"
|
||||
paddingLeft={4}
|
||||
flexShrink={0}
|
||||
>
|
||||
{row.bucket && row.bucket.remainingFraction != null && (
|
||||
<Box flexDirection="row" flexShrink={0}>
|
||||
{renderProgressBar(
|
||||
effectiveUsedFraction,
|
||||
statusColor,
|
||||
progressBarWidth,
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
width={percentageWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
{row.bucket && row.bucket.remainingFraction != null && (
|
||||
<Box>
|
||||
{row.bucket.remainingFraction === 0 ? (
|
||||
<Text color={theme.status.error} wrap="truncate-end">
|
||||
Limit
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={statusColor} wrap="truncate-end">
|
||||
{percentageText}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
width={resetWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-start"
|
||||
paddingLeft={2}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{row.bucket?.resetTime &&
|
||||
formatResetTime(row.bucket.resetTime, 'column')
|
||||
? formatResetTime(row.bucket.resetTime, 'column')
|
||||
: ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{cacheEfficiency > 0 && !showQuotaColumn && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
|
||||
@@ -28,84 +28,20 @@ describe('ToastDisplay', () => {
|
||||
|
||||
describe('shouldShowToast', () => {
|
||||
const baseState: Partial<UIState> = {
|
||||
ctrlCPressedOnce: false,
|
||||
transientMessage: null,
|
||||
ctrlDPressedOnce: false,
|
||||
showEscapePrompt: false,
|
||||
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),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when transientMessage is present', () => {
|
||||
expect(
|
||||
shouldShowToast({
|
||||
...baseState,
|
||||
transientMessage: { text: 'test', type: TransientMessageType.Hint },
|
||||
} as UIState),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when ctrlDPressedOnce is true', () => {
|
||||
expect(
|
||||
shouldShowToast({ ...baseState, ctrlDPressedOnce: true } as UIState),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when showEscapePrompt is true and buffer is NOT empty', () => {
|
||||
expect(
|
||||
shouldShowToast({
|
||||
...baseState,
|
||||
showEscapePrompt: true,
|
||||
buffer: { text: 'some text' } as TextBuffer,
|
||||
} as UIState),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when showEscapePrompt is true and history is NOT empty', () => {
|
||||
expect(
|
||||
shouldShowToast({
|
||||
...baseState,
|
||||
showEscapePrompt: true,
|
||||
history: [{ id: '1' } as unknown as HistoryItem],
|
||||
} as UIState),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when showEscapePrompt is true but buffer and history are empty', () => {
|
||||
expect(
|
||||
shouldShowToast({
|
||||
...baseState,
|
||||
showEscapePrompt: true,
|
||||
} as UIState),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when queueErrorMessage is present', () => {
|
||||
expect(
|
||||
shouldShowToast({
|
||||
...baseState,
|
||||
queueErrorMessage: 'error',
|
||||
transientMessage: { message: 'test', type: TransientMessageType.Hint },
|
||||
} as UIState),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -117,18 +53,10 @@ describe('ToastDisplay', () => {
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
});
|
||||
|
||||
it('renders Ctrl+C prompt', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderToastDisplay({
|
||||
ctrlCPressedOnce: true,
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders warning message', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderToastDisplay({
|
||||
transientMessage: {
|
||||
text: 'This is a warning',
|
||||
message: 'This is a warning',
|
||||
type: TransientMessageType.Warning,
|
||||
},
|
||||
});
|
||||
@@ -139,7 +67,7 @@ describe('ToastDisplay', () => {
|
||||
it('renders hint message', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderToastDisplay({
|
||||
transientMessage: {
|
||||
text: 'This is a hint',
|
||||
message: 'This is a hint',
|
||||
type: TransientMessageType.Hint,
|
||||
},
|
||||
});
|
||||
@@ -147,59 +75,36 @@ describe('ToastDisplay', () => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders Ctrl+D prompt', async () => {
|
||||
it('renders Error transient message', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderToastDisplay({
|
||||
ctrlDPressedOnce: true,
|
||||
transientMessage: {
|
||||
message: 'Error Message',
|
||||
type: TransientMessageType.Error,
|
||||
},
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders Escape prompt when buffer is empty', async () => {
|
||||
it('renders Hint transient message', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderToastDisplay({
|
||||
showEscapePrompt: true,
|
||||
history: [{ id: 1, type: 'user', text: 'test' }] as HistoryItem[],
|
||||
transientMessage: {
|
||||
message: 'Hint Message',
|
||||
type: TransientMessageType.Hint,
|
||||
},
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders Escape prompt when buffer is NOT empty', async () => {
|
||||
it('renders Accent transient message', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderToastDisplay({
|
||||
showEscapePrompt: true,
|
||||
buffer: { text: 'some text' } as TextBuffer,
|
||||
transientMessage: {
|
||||
message: 'Accent Message',
|
||||
type: TransientMessageType.Accent,
|
||||
},
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders Queue Error Message', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderToastDisplay({
|
||||
queueErrorMessage: 'Queue Error',
|
||||
});
|
||||
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(
|
||||
'Ctrl+O to show more lines of the last response',
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
'Ctrl+O to collapse lines of the last response',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,75 +11,45 @@ import { useUIState, type UIState } from '../contexts/UIStateContext.js';
|
||||
import { TransientMessageType } from '../../utils/events.js';
|
||||
|
||||
export function shouldShowToast(uiState: UIState): boolean {
|
||||
return (
|
||||
uiState.ctrlCPressedOnce ||
|
||||
Boolean(uiState.transientMessage) ||
|
||||
uiState.ctrlDPressedOnce ||
|
||||
(uiState.showEscapePrompt &&
|
||||
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
|
||||
Boolean(uiState.queueErrorMessage) ||
|
||||
uiState.showIsExpandableHint
|
||||
);
|
||||
return Boolean(uiState.transientMessage);
|
||||
}
|
||||
|
||||
export const ToastDisplay: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
|
||||
if (uiState.ctrlCPressedOnce) {
|
||||
if (
|
||||
uiState.transientMessage?.type === TransientMessageType.Warning &&
|
||||
uiState.transientMessage.message
|
||||
) {
|
||||
return (
|
||||
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text>
|
||||
<Text color={theme.status.warning}>{uiState.transientMessage.message}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
uiState.transientMessage?.type === TransientMessageType.Warning &&
|
||||
uiState.transientMessage.text
|
||||
uiState.transientMessage?.type === TransientMessageType.Error &&
|
||||
uiState.transientMessage.message
|
||||
) {
|
||||
return (
|
||||
<Text color={theme.status.warning}>{uiState.transientMessage.text}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.ctrlDPressedOnce) {
|
||||
return (
|
||||
<Text color={theme.status.warning}>Press Ctrl+D again to exit.</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.showEscapePrompt) {
|
||||
const isPromptEmpty = uiState.buffer.text.length === 0;
|
||||
const hasHistory = uiState.history.length > 0;
|
||||
|
||||
if (isPromptEmpty && !hasHistory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text color={theme.text.secondary}>
|
||||
Press Esc again to {isPromptEmpty ? 'rewind' : 'clear prompt'}.
|
||||
</Text>
|
||||
<Text color={theme.status.error}>{uiState.transientMessage.message}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
uiState.transientMessage?.type === TransientMessageType.Hint &&
|
||||
uiState.transientMessage.text
|
||||
uiState.transientMessage.message
|
||||
) {
|
||||
return (
|
||||
<Text color={theme.text.secondary}>{uiState.transientMessage.text}</Text>
|
||||
<Text color={theme.text.secondary}>{uiState.transientMessage.message}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.queueErrorMessage) {
|
||||
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
|
||||
}
|
||||
|
||||
if (uiState.showIsExpandableHint) {
|
||||
const action = uiState.constrainHeight ? 'show more' : 'collapse';
|
||||
if (
|
||||
uiState.transientMessage?.type === TransientMessageType.Accent &&
|
||||
uiState.transientMessage.message
|
||||
) {
|
||||
return (
|
||||
<Text color={theme.text.accent}>
|
||||
Ctrl+O to {action} lines of the last response
|
||||
</Text>
|
||||
<Text color={theme.text.accent}>{uiState.transientMessage.message}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import type { ConfirmingToolState } from '../hooks/useConfirmingTool.js';
|
||||
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { ShowMoreLines } from './ShowMoreLines.js';
|
||||
import { StickyHeader } from './StickyHeader.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import type { SerializableConfirmationDetails } from '@google/gemini-cli-core';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
|
||||
@@ -43,7 +42,6 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
const { getPreferredEditor } = useUIActions();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const {
|
||||
mainAreaWidth,
|
||||
terminalHeight,
|
||||
@@ -157,10 +155,5 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
</>
|
||||
);
|
||||
|
||||
return isAlternateBuffer ? (
|
||||
/* Shadow the global provider to maintain isolation in ASB mode. */
|
||||
<OverflowProvider>{content}</OverflowProvider>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
return <OverflowProvider>{content}</OverflowProvider>;
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] =
|
||||
|
||||
exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `
|
||||
" workspace (/directory) sandbox /model /stats
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15%
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85%
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -40,6 +40,6 @@ exports[`<Footer /> > footer configuration filtering (golden snapshots) > render
|
||||
|
||||
exports[`<Footer /> > hides the usage indicator when usage is not near limit 1`] = `
|
||||
" workspace (/directory) sandbox /model /stats
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85%
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15%
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -18,7 +18,6 @@ AppHeader(full)
|
||||
│ Line 19 █ │
|
||||
│ Line 20 █ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Press Ctrl+O to show more lines
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -40,7 +39,6 @@ AppHeader(full)
|
||||
│ Line 19 █ │
|
||||
│ Line 20 █ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Press Ctrl+O to show more lines
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`QuotaDisplay > should NOT render reset time when terse is true 1`] = `
|
||||
"15%
|
||||
"85%
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`QuotaDisplay > should render red when usage < 5% 1`] = `
|
||||
"/stats 4% usage remaining
|
||||
exports[`QuotaDisplay > should render critical when used >= 95% 1`] = `
|
||||
"96% used
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -15,12 +15,12 @@ exports[`QuotaDisplay > should render terse limit reached message 1`] = `
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`QuotaDisplay > should render with reset time when provided 1`] = `
|
||||
"/stats 15% usage remaining, resets in 1h
|
||||
exports[`QuotaDisplay > should render warning when used >= 80% 1`] = `
|
||||
"85% used
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`QuotaDisplay > should render yellow when usage < 20% 1`] = `
|
||||
"/stats 15% usage remaining
|
||||
exports[`QuotaDisplay > should render with reset time when provided 1`] = `
|
||||
"85% used (Limit resets in 1h)
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -17,10 +17,9 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
|
||||
│ » API Time: 50.2s (100.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ Model Usage │
|
||||
│ Model Reqs Input Tokens Cache Reads Output Tokens │
|
||||
│ ──────────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 10 500 500 2,000 │
|
||||
│ Model Reqs Input Tokens Cache Reads Output Tokens │
|
||||
│ ──────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 10 500 500 2,000 │
|
||||
│ │
|
||||
│ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │
|
||||
│ │
|
||||
|
||||
@@ -117,10 +117,9 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency secti
|
||||
│ » API Time: 100ms (100.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ Model Usage │
|
||||
│ Model Reqs Input Tokens Cache Reads Output Tokens │
|
||||
│ ──────────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 1 100 0 100 │
|
||||
│ Model Reqs Input Tokens Cache Reads Output Tokens │
|
||||
│ ──────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 1 100 0 100 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
@@ -162,16 +161,15 @@ exports[`<StatsDisplay /> > Quota Display > renders pooled quota information for
|
||||
│ » API Time: 0s (0.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ auto Usage │
|
||||
│ 65% usage remaining │
|
||||
│ 35% used │
|
||||
│ Usage limit: 1,100 │
|
||||
│ Usage limits span all sessions and reset daily. │
|
||||
│ For a full token breakdown, run \`/stats model\`. │
|
||||
│ │
|
||||
│ Model Reqs Usage remaining │
|
||||
│ ──────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro - │
|
||||
│ gemini-2.5-flash - │
|
||||
│ Model Reqs Model usage Usage resets │
|
||||
│ ──────────────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 90% │
|
||||
│ gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 30% │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
@@ -193,10 +191,9 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information for unused
|
||||
│ » API Time: 0s (0.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ Model Usage │
|
||||
│ Model Reqs Usage remaining │
|
||||
│ ──────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-flash - 50.0% resets in 2h │
|
||||
│ Model Reqs Model usage Usage resets │
|
||||
│ ──────────────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 50% 2:00 PM (2h) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
@@ -218,10 +215,9 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information when quota
|
||||
│ » API Time: 100ms (100.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ Model Usage │
|
||||
│ Model Reqs Usage remaining │
|
||||
│ ──────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 1 75.0% resets in 1h 30m │
|
||||
│ Model Reqs Model usage Usage resets │
|
||||
│ ──────────────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 1 ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 25% 1:30 PM (1h 30m) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
@@ -283,11 +279,10 @@ exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
|
||||
│ » API Time: 19.5s (100.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ Model Usage │
|
||||
│ Model Reqs Input Tokens Cache Reads Output Tokens │
|
||||
│ ──────────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 3 500 500 2,000 │
|
||||
│ gemini-2.5-flash 5 15,000 10,000 15,000 │
|
||||
│ Model Reqs Input Tokens Cache Reads Output Tokens │
|
||||
│ ──────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 3 500 500 2,000 │
|
||||
│ gemini-2.5-flash 5 15,000 10,000 15,000 │
|
||||
│ │
|
||||
│ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │
|
||||
│ │
|
||||
@@ -312,10 +307,9 @@ exports[`<StatsDisplay /> > renders all sections when all data is present 1`] =
|
||||
│ » API Time: 100ms (44.8%) │
|
||||
│ » Tool Time: 123ms (55.2%) │
|
||||
│ │
|
||||
│ Model Usage │
|
||||
│ Model Reqs Input Tokens Cache Reads Output Tokens │
|
||||
│ ──────────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 1 50 50 100 │
|
||||
│ Model Reqs Input Tokens Cache Reads Output Tokens │
|
||||
│ ──────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 1 50 50 100 │
|
||||
│ │
|
||||
│ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │
|
||||
│ │
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ToastDisplay > renders Ctrl+C prompt 1`] = `
|
||||
"Press Ctrl+C again to exit.
|
||||
exports[`ToastDisplay > renders Accent transient message 1`] = `
|
||||
"Accent Message
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToastDisplay > renders Ctrl+D prompt 1`] = `
|
||||
"Press Ctrl+D again to exit.
|
||||
exports[`ToastDisplay > renders Error transient message 1`] = `
|
||||
"Error Message
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToastDisplay > renders Escape prompt when buffer is NOT empty 1`] = `
|
||||
"Press Esc again to clear prompt.
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToastDisplay > renders Escape prompt when buffer is empty 1`] = `
|
||||
"Press Esc again to rewind.
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToastDisplay > renders Queue Error Message 1`] = `
|
||||
"Queue Error
|
||||
exports[`ToastDisplay > renders Hint transient message 1`] = `
|
||||
"Hint Message
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai
|
||||
│ 4. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Press Ctrl+O to show more lines
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
@@ -7,12 +7,9 @@
|
||||
import type React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { ShowMoreLines } from '../ShowMoreLines.js';
|
||||
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,8 +28,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
const prefix = '✦ ';
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const content = (
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={theme.text.accent} aria-label={SCREEN_READER_MODEL_PREFIX}>
|
||||
@@ -44,26 +40,14 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
isAlternateBuffer || availableTerminalHeight === undefined
|
||||
availableTerminalHeight === undefined
|
||||
? undefined
|
||||
: Math.max(availableTerminalHeight - 1, 1)
|
||||
}
|
||||
terminalWidth={Math.max(terminalWidth - prefixWidth, 0)}
|
||||
renderMarkdown={renderMarkdown}
|
||||
/>
|
||||
<Box>
|
||||
<ShowMoreLines
|
||||
constrainHeight={availableTerminalHeight !== undefined}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return isAlternateBuffer ? (
|
||||
/* Shadow the global provider to maintain isolation in ASB mode. */
|
||||
<OverflowProvider>{content}</OverflowProvider>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
import type React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { ShowMoreLines } from '../ShowMoreLines.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
|
||||
interface GeminiMessageContentProps {
|
||||
text: string;
|
||||
@@ -31,7 +29,6 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const { renderMarkdown } = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const originalPrefix = '✦ ';
|
||||
const prefixWidth = originalPrefix.length;
|
||||
|
||||
@@ -41,18 +38,13 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
isAlternateBuffer || availableTerminalHeight === undefined
|
||||
availableTerminalHeight === undefined
|
||||
? undefined
|
||||
: Math.max(availableTerminalHeight - 1, 1)
|
||||
}
|
||||
terminalWidth={Math.max(terminalWidth - prefixWidth, 0)}
|
||||
renderMarkdown={renderMarkdown}
|
||||
/>
|
||||
<Box>
|
||||
<ShowMoreLines
|
||||
constrainHeight={availableTerminalHeight !== undefined}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -195,7 +195,7 @@ describe('<ShellToolMessage />', () => {
|
||||
[
|
||||
'uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large',
|
||||
100,
|
||||
ACTIVE_SHELL_MAX_LINES,
|
||||
ACTIVE_SHELL_MAX_LINES - 3,
|
||||
false,
|
||||
],
|
||||
[
|
||||
@@ -207,7 +207,7 @@ describe('<ShellToolMessage />', () => {
|
||||
[
|
||||
'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined',
|
||||
undefined,
|
||||
ACTIVE_SHELL_MAX_LINES,
|
||||
ACTIVE_SHELL_MAX_LINES - 3,
|
||||
false,
|
||||
],
|
||||
])('%s', async (_, availableTerminalHeight, expectedMaxLines, focused) => {
|
||||
@@ -301,8 +301,8 @@ describe('<ShellToolMessage />', () => {
|
||||
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);
|
||||
// Should still be constrained to 12 (15 - 3) because isExpandable is false
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(12);
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
|
||||
@@ -24,8 +24,16 @@ import type { ToolMessageProps } from './ToolMessage.js';
|
||||
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { calculateShellMaxLines } from '../../utils/toolLayoutUtils.js';
|
||||
import {
|
||||
type Config,
|
||||
ShellExecutionService,
|
||||
CoreToolCallStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
calculateShellMaxLines,
|
||||
calculateToolContentMaxLines,
|
||||
SHELL_CONTENT_OVERHEAD,
|
||||
} from '../../utils/toolLayoutUtils.js';
|
||||
|
||||
export interface ShellToolMessageProps extends ToolMessageProps {
|
||||
config?: Config;
|
||||
@@ -78,6 +86,47 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
embeddedShellFocused,
|
||||
);
|
||||
|
||||
const maxLines = calculateShellMaxLines({
|
||||
status,
|
||||
isAlternateBuffer,
|
||||
isThisShellFocused,
|
||||
availableTerminalHeight,
|
||||
constrainHeight,
|
||||
isExpandable,
|
||||
});
|
||||
|
||||
const availableHeight = calculateToolContentMaxLines({
|
||||
availableTerminalHeight,
|
||||
isAlternateBuffer,
|
||||
maxLinesLimit: maxLines,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const isExecuting = status === CoreToolCallStatus.Executing;
|
||||
if (isExecuting && ptyId) {
|
||||
try {
|
||||
const childWidth = terminalWidth - 4; // account for padding and borders
|
||||
const finalHeight =
|
||||
availableHeight ?? ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD;
|
||||
|
||||
ShellExecutionService.resizePty(
|
||||
ptyId,
|
||||
Math.max(1, childWidth),
|
||||
Math.max(1, finalHeight),
|
||||
);
|
||||
} catch (e) {
|
||||
if (
|
||||
!(
|
||||
e instanceof Error &&
|
||||
e.message.includes('Cannot resize a pty that has already exited')
|
||||
)
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [ptyId, status, terminalWidth, availableHeight]);
|
||||
|
||||
const { setEmbeddedShellFocused } = useUIActions();
|
||||
const wasFocusedRef = React.useRef(false);
|
||||
|
||||
@@ -166,14 +215,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
terminalWidth={terminalWidth}
|
||||
renderOutputAsMarkdown={renderOutputAsMarkdown}
|
||||
hasFocus={isThisShellFocused}
|
||||
maxLines={calculateShellMaxLines({
|
||||
status,
|
||||
isAlternateBuffer,
|
||||
isThisShellFocused,
|
||||
availableTerminalHeight,
|
||||
constrainHeight,
|
||||
isExpandable,
|
||||
})}
|
||||
maxLines={maxLines}
|
||||
/>
|
||||
{isThisShellFocused && config && (
|
||||
<ShellInputPrompt
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
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,
|
||||
@@ -767,200 +766,4 @@ 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,
|
||||
settings: fullVerbositySettings,
|
||||
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,
|
||||
settings: fullVerbositySettings,
|
||||
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,
|
||||
settings: fullVerbositySettings,
|
||||
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,
|
||||
settings: fullVerbositySettings,
|
||||
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,
|
||||
settings: fullVerbositySettings,
|
||||
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,
|
||||
settings: fullVerbositySettings,
|
||||
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,18 +17,12 @@ import { ToolMessage } from './ToolMessage.js';
|
||||
import { ShellToolMessage } from './ShellToolMessage.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { isShellTool, isThisShellFocused } from './ToolShared.js';
|
||||
import { isShellTool } from './ToolShared.js';
|
||||
import {
|
||||
shouldHideToolCall,
|
||||
CoreToolCallStatus,
|
||||
} 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';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
|
||||
@@ -83,13 +77,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
|
||||
const config = useConfig();
|
||||
const {
|
||||
constrainHeight,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
backgroundShells,
|
||||
pendingHistoryItems,
|
||||
} = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const { borderColor, borderDimColor } = useMemo(
|
||||
() =>
|
||||
@@ -149,72 +141,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
|
||||
const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN;
|
||||
|
||||
/*
|
||||
* 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;
|
||||
@@ -307,12 +233,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
/>
|
||||
)
|
||||
}
|
||||
{(borderBottomOverride ?? true) && visibleToolCalls.length > 0 && (
|
||||
<ShowMoreLines
|
||||
constrainHeight={constrainHeight && !!isExpandable}
|
||||
isOverflowing={hasOverflow}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
status={status}
|
||||
description={description}
|
||||
emphasis={emphasis}
|
||||
progressMessage={progressMessage}
|
||||
originalRequestName={originalRequestName}
|
||||
/>
|
||||
<FocusHint
|
||||
|
||||
@@ -8,21 +8,19 @@ 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';
|
||||
import { useOverflowState } from '../../contexts/OverflowContext.js';
|
||||
|
||||
describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay synchronization', () => {
|
||||
it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Alternate Buffer (ASB) mode', async () => {
|
||||
it('should ensure ToolGroupMessage correctly reports overflow to the global state 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.
|
||||
* 1. availableTerminalHeight(13) - staticHeight(1) - ASB Reserved(6) = 6 lines per tool.
|
||||
* 2. 10 lines of output > 6 lines budget => hasOverflow should be TRUE.
|
||||
*/
|
||||
|
||||
const lines = Array.from({ length: 5 }, (_, i) => `line ${i + 1}`);
|
||||
const lines = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`);
|
||||
const resultDisplay = lines.join('\n');
|
||||
|
||||
const toolCalls: IndividualToolCallDisplay[] = [
|
||||
@@ -36,8 +34,15 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
let latestOverflowState: ReturnType<typeof useOverflowState>;
|
||||
const StateCapture = () => {
|
||||
latestOverflowState = useOverflowState();
|
||||
return null;
|
||||
};
|
||||
|
||||
const { unmount, waitUntilReady } = renderWithProviders(
|
||||
<>
|
||||
<StateCapture />
|
||||
<ToolGroupMessage
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
@@ -45,7 +50,7 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
|
||||
terminalWidth={80}
|
||||
isExpandable={true}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
</>,
|
||||
{
|
||||
uiState: {
|
||||
streamingState: StreamingState.Idle,
|
||||
@@ -55,24 +60,26 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
|
||||
},
|
||||
);
|
||||
|
||||
// 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',
|
||||
),
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// To verify that the overflow state was indeed updated by the Scrollable component.
|
||||
await waitFor(() => {
|
||||
expect(latestOverflowState?.overflowingIds.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Standard mode', async () => {
|
||||
it('should ensure ToolGroupMessage correctly reports overflow 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.
|
||||
* 1. availableTerminalHeight(13) passed to ToolGroupMessage.
|
||||
* 2. ToolGroupMessage subtracts its static height (2) => 11 lines available for tools.
|
||||
* 3. ToolResultDisplay gets 11 lines, subtracts static height (1) and Standard Reserved (2) => 8 lines.
|
||||
* 4. 15 lines of output > 8 lines budget => hasOverflow should be TRUE.
|
||||
*/
|
||||
|
||||
const lines = Array.from({ length: 9 }, (_, i) => `line ${i + 1}`);
|
||||
const lines = Array.from({ length: 15 }, (_, i) => `line ${i + 1}`);
|
||||
const resultDisplay = lines.join('\n');
|
||||
|
||||
const toolCalls: IndividualToolCallDisplay[] = [
|
||||
@@ -86,16 +93,14 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<ToolGroupMessage
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={13}
|
||||
terminalWidth={80}
|
||||
isExpandable={true}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={13}
|
||||
terminalWidth={80}
|
||||
isExpandable={true}
|
||||
/>,
|
||||
{
|
||||
uiState: {
|
||||
streamingState: StreamingState.Idle,
|
||||
@@ -105,11 +110,11 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
// Verify truncation is occurring (standard mode uses MaxSizedBox)
|
||||
await waitFor(() => expect(lastFrame()).toContain('hidden (Ctrl+O'));
|
||||
|
||||
// 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.
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -236,6 +236,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
maxHeight={maxLines ?? availableHeight}
|
||||
hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down)
|
||||
scrollToBottom={true}
|
||||
reportOverflow={true}
|
||||
>
|
||||
{content}
|
||||
</Scrollable>
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* @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 { waitFor } from '../../../test-utils/async.js';
|
||||
import { CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
|
||||
describe('ToolResultDisplay Overflow', () => {
|
||||
it('should display "press ctrl-o" hint when content overflows in ToolGroupMessage', async () => {
|
||||
// Large output that will definitely overflow
|
||||
const lines = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
lines.push(`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, waitUntilReady } = renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={15} // Small height to force overflow
|
||||
terminalWidth={80}
|
||||
isExpandable={true}
|
||||
/>,
|
||||
{
|
||||
uiState: {
|
||||
streamingState: StreamingState.Idle,
|
||||
constrainHeight: true,
|
||||
},
|
||||
useAlternateBuffer: true,
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
// In ASB mode the overflow hint can render before the scroll position
|
||||
// settles. Wait for both the hint and the tail of the content so this
|
||||
// snapshot is deterministic across slower CI runners.
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toBeDefined();
|
||||
expect(frame?.toLowerCase()).toContain('press ctrl+o to show more lines');
|
||||
expect(frame).toContain('line 50');
|
||||
});
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(frame).toBeDefined();
|
||||
if (frame) {
|
||||
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.toLowerCase().includes('press ctrl+o to show more lines'),
|
||||
);
|
||||
expect(hintIndex).toBeGreaterThan(bottomBorderIndex);
|
||||
expect(frame).toMatchSnapshot();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -192,6 +192,7 @@ type ToolInfoProps = {
|
||||
description: string;
|
||||
status: CoreToolCallStatus;
|
||||
emphasis: TextEmphasis;
|
||||
progressMessage?: string;
|
||||
originalRequestName?: string;
|
||||
};
|
||||
|
||||
@@ -200,6 +201,7 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
|
||||
description,
|
||||
status: coreStatus,
|
||||
emphasis,
|
||||
progressMessage: _progressMessage,
|
||||
originalRequestName,
|
||||
}) => {
|
||||
const status = mapCoreStatusToDisplayStatus(coreStatus);
|
||||
|
||||
+6
-15
@@ -4,9 +4,6 @@ exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MA
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command A shell command │
|
||||
│ │
|
||||
│ Line 86 │
|
||||
│ Line 87 │
|
||||
│ Line 88 │
|
||||
│ Line 89 │
|
||||
│ Line 90 │
|
||||
│ Line 91 │
|
||||
@@ -16,8 +13,8 @@ exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MA
|
||||
│ Line 95 │
|
||||
│ Line 96 │
|
||||
│ Line 97 │
|
||||
│ Line 98 ▄ │
|
||||
│ Line 99 █ │
|
||||
│ Line 98 │
|
||||
│ Line 99 ▄ │
|
||||
│ Line 100 █ │
|
||||
"
|
||||
`;
|
||||
@@ -148,9 +145,6 @@ exports[`<ShellToolMessage /> > Height Constraints > stays constrained in altern
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ Shell Command A shell command │
|
||||
│ │
|
||||
│ Line 86 │
|
||||
│ Line 87 │
|
||||
│ Line 88 │
|
||||
│ Line 89 │
|
||||
│ Line 90 │
|
||||
│ Line 91 │
|
||||
@@ -160,8 +154,8 @@ exports[`<ShellToolMessage /> > Height Constraints > stays constrained in altern
|
||||
│ Line 95 │
|
||||
│ Line 96 │
|
||||
│ Line 97 │
|
||||
│ Line 98 ▄ │
|
||||
│ Line 99 █ │
|
||||
│ Line 98 │
|
||||
│ Line 99 ▄ │
|
||||
│ Line 100 █ │
|
||||
"
|
||||
`;
|
||||
@@ -170,9 +164,6 @@ exports[`<ShellToolMessage /> > Height Constraints > uses ACTIVE_SHELL_MAX_LINES
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command A shell command │
|
||||
│ │
|
||||
│ Line 86 │
|
||||
│ Line 87 │
|
||||
│ Line 88 │
|
||||
│ Line 89 │
|
||||
│ Line 90 │
|
||||
│ Line 91 │
|
||||
@@ -182,8 +173,8 @@ exports[`<ShellToolMessage /> > Height Constraints > uses ACTIVE_SHELL_MAX_LINES
|
||||
│ Line 95 │
|
||||
│ Line 96 │
|
||||
│ Line 97 │
|
||||
│ Line 98 ▄ │
|
||||
│ Line 99 █ │
|
||||
│ Line 98 │
|
||||
│ Line 99 ▄ │
|
||||
│ Line 100 █ │
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -5,13 +5,22 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useRef, useCallback, useMemo, useLayoutEffect } from 'react';
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useLayoutEffect,
|
||||
useEffect,
|
||||
useId,
|
||||
} from 'react';
|
||||
import { Box, ResizeObserver, type DOMElement } from 'ink';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { useScrollable } from '../../contexts/ScrollProvider.js';
|
||||
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
|
||||
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
import { useOverflowActions } from '../../contexts/OverflowContext.js';
|
||||
|
||||
interface ScrollableProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -22,6 +31,7 @@ interface ScrollableProps {
|
||||
hasFocus: boolean;
|
||||
scrollToBottom?: boolean;
|
||||
flexGrow?: number;
|
||||
reportOverflow?: boolean;
|
||||
}
|
||||
|
||||
export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
@@ -33,10 +43,13 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
hasFocus,
|
||||
scrollToBottom,
|
||||
flexGrow,
|
||||
reportOverflow = false,
|
||||
}) => {
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const viewportRef = useRef<DOMElement | null>(null);
|
||||
const contentRef = useRef<DOMElement | null>(null);
|
||||
const overflowActions = useOverflowActions();
|
||||
const id = useId();
|
||||
const [size, setSize] = useState({
|
||||
innerHeight: typeof height === 'number' ? height : 0,
|
||||
scrollHeight: 0,
|
||||
@@ -52,6 +65,27 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
scrollTopRef.current = scrollTop;
|
||||
}, [scrollTop]);
|
||||
|
||||
useEffect(() => {
|
||||
if (reportOverflow && size.scrollHeight > size.innerHeight) {
|
||||
overflowActions?.addOverflowingId?.(id);
|
||||
} else {
|
||||
overflowActions?.removeOverflowingId?.(id);
|
||||
}
|
||||
}, [
|
||||
reportOverflow,
|
||||
size.scrollHeight,
|
||||
size.innerHeight,
|
||||
id,
|
||||
overflowActions,
|
||||
]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
overflowActions?.removeOverflowingId?.(id);
|
||||
},
|
||||
[id, overflowActions],
|
||||
);
|
||||
|
||||
const viewportObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const contentObserverRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export const INFORMATIVE_TIPS = [
|
||||
'Define custom context file names, like CONTEXT.md (settings.json)…',
|
||||
'Set max directories to scan for context files (/settings)…',
|
||||
'Expand your workspace with additional directories (/directory)…',
|
||||
'Control how /memory refresh loads context files (/settings)…',
|
||||
'Control how /memory reload loads context files (/settings)…',
|
||||
'Toggle respect for .gitignore files in context (/settings)…',
|
||||
'Toggle respect for .geminiignore files in context (/settings)…',
|
||||
'Enable recursive file search for @-file completions (/settings)…',
|
||||
@@ -142,10 +142,10 @@ export const INFORMATIVE_TIPS = [
|
||||
'Create a project-specific GEMINI.md file with /init…',
|
||||
'List configured MCP servers and tools with /mcp list…',
|
||||
'Authenticate with an OAuth-enabled MCP server with /mcp auth…',
|
||||
'Restart MCP servers with /mcp refresh…',
|
||||
'Reload MCP servers with /mcp reload…',
|
||||
'See the current instructional context with /memory show…',
|
||||
'Add content to the instructional memory with /memory add…',
|
||||
'Reload instructional context from GEMINI.md files with /memory refresh…',
|
||||
'Reload instructional context from GEMINI.md files with /memory reload…',
|
||||
'List the paths of the GEMINI.md files in use with /memory list…',
|
||||
'Choose your Gemini model with /model…',
|
||||
'Display the privacy notice with /privacy…',
|
||||
|
||||
@@ -55,7 +55,6 @@ export interface UIActions {
|
||||
handleFolderTrustSelect: (choice: FolderTrustChoice) => void;
|
||||
setIsPolicyUpdateDialogOpen: (value: boolean) => void;
|
||||
setConstrainHeight: (value: boolean) => void;
|
||||
onEscapePromptChange: (show: boolean) => void;
|
||||
refreshStatic: () => void;
|
||||
handleFinalSubmit: (value: string) => Promise<void>;
|
||||
handleClearScreen: () => void;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
import { type TransientMessageType } from '../../utils/events.js';
|
||||
import type {
|
||||
HistoryItem,
|
||||
ThoughtSummary,
|
||||
@@ -31,7 +32,6 @@ import type {
|
||||
FolderDiscoveryResults,
|
||||
PolicyUpdateConfirmationRequest,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type TransientMessageType } from '../../utils/events.js';
|
||||
import type { DOMElement } from 'ink';
|
||||
import type { SessionStatsState } from '../contexts/SessionContext.js';
|
||||
import type { ExtensionUpdateState } from '../state/extensions.js';
|
||||
@@ -161,9 +161,6 @@ export interface UIState {
|
||||
filteredConsoleMessages: ConsoleMessageItem[];
|
||||
ideContextState: IdeContext | undefined;
|
||||
renderMarkdown: boolean;
|
||||
ctrlCPressedOnce: boolean;
|
||||
ctrlDPressedOnce: boolean;
|
||||
showEscapePrompt: boolean;
|
||||
shortcutsHelpVisible: boolean;
|
||||
cleanUiDetailsVisible: boolean;
|
||||
elapsedTime: number;
|
||||
@@ -171,7 +168,6 @@ export interface UIState {
|
||||
historyRemountKey: number;
|
||||
activeHooks: ActiveHook[];
|
||||
messageQueue: string[];
|
||||
queueErrorMessage: string | null;
|
||||
showApprovalModeIndicator: ApprovalMode;
|
||||
allowPlanMode: boolean;
|
||||
// Quota-related state
|
||||
@@ -220,11 +216,10 @@ export interface UIState {
|
||||
isBackgroundShellListOpen: boolean;
|
||||
adminSettingsChanged: boolean;
|
||||
newAgents: AgentDefinition[] | null;
|
||||
showIsExpandableHint: boolean;
|
||||
hintMode: boolean;
|
||||
hintBuffer: string;
|
||||
transientMessage: {
|
||||
text: string;
|
||||
message: string;
|
||||
type: TransientMessageType;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -502,7 +502,9 @@ export const useSlashCommandProcessor = (
|
||||
const props = result.props as Record<string, unknown>;
|
||||
if (
|
||||
!props ||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof props['name'] !== 'string' ||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof props['displayName'] !== 'string' ||
|
||||
!props['definition']
|
||||
) {
|
||||
|
||||
@@ -325,7 +325,6 @@ describe('toolMapping', () => {
|
||||
const result = mapToDisplay(toolCall);
|
||||
expect(result.tools[0].originalRequestName).toBe('original_tool');
|
||||
});
|
||||
|
||||
it('propagates isClientInitiated from tool request', () => {
|
||||
const clientInitiatedTool: ScheduledToolCall = {
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useLayoutEffect, type RefObject } from 'react';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { type DOMElement, measureElement } from 'ink';
|
||||
import { useTerminalSize } from './useTerminalSize.js';
|
||||
|
||||
export const isAlternateBufferEnabled = (config: Config): boolean =>
|
||||
config.getUseAlternateBuffer();
|
||||
@@ -15,3 +18,28 @@ export const useAlternateBuffer = (): boolean => {
|
||||
const config = useConfig();
|
||||
return isAlternateBufferEnabled(config);
|
||||
};
|
||||
|
||||
export const useLegacyNonAlternateBufferMode = (
|
||||
rootUiRef: RefObject<DOMElement | null>,
|
||||
isAlternateBuffer: boolean,
|
||||
): boolean => {
|
||||
const { rows: terminalHeight } = useTerminalSize();
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isAlternateBuffer || !rootUiRef.current) {
|
||||
if (isOverflowing) setIsOverflowing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const measurement = measureElement(rootUiRef.current);
|
||||
// If the interactive UI is taller than the terminal height, we have a problem.
|
||||
const currentlyOverflowing = measurement.height >= terminalHeight;
|
||||
|
||||
if (currentlyOverflowing !== isOverflowing) {
|
||||
setIsOverflowing(currentlyOverflowing);
|
||||
}
|
||||
}, [isAlternateBuffer, rootUiRef, terminalHeight, isOverflowing]);
|
||||
|
||||
return !isAlternateBuffer && isOverflowing;
|
||||
};
|
||||
|
||||
@@ -7,36 +7,41 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* A hook to manage a state value that automatically resets to null after a duration.
|
||||
* Useful for transient UI messages, hints, or warnings.
|
||||
* A hook to manage a state value that automatically resets to null after a specified duration.
|
||||
* Provides a function to manually set the value and reset the timer.
|
||||
* Can be paused to prevent the timer from expiring.
|
||||
*/
|
||||
export function useTimedMessage<T>(durationMs: number) {
|
||||
export function useTimedMessage<T>(
|
||||
defaultDurationMs: number,
|
||||
isPaused: boolean = false,
|
||||
) {
|
||||
const [message, setMessage] = useState<T | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const currentDurationRef = useRef<number>(defaultDurationMs);
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
if (!isPaused && message !== null) {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setMessage(null);
|
||||
}, currentDurationRef.current);
|
||||
}
|
||||
}, [isPaused, message]);
|
||||
|
||||
const showMessage = useCallback(
|
||||
(msg: T | null) => {
|
||||
(msg: T | null, durationMs?: number) => {
|
||||
setMessage(msg);
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
if (msg !== null) {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setMessage(null);
|
||||
}, durationMs);
|
||||
}
|
||||
currentDurationRef.current = durationMs ?? defaultDurationMs;
|
||||
},
|
||||
[durationMs],
|
||||
[defaultDurationMs],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
startTimer();
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, [startTimer]);
|
||||
|
||||
return [message, showMessage] as const;
|
||||
}
|
||||
|
||||
@@ -117,6 +117,20 @@ export function useToolScheduler(
|
||||
const handler = (event: ToolCallsUpdateMessage) => {
|
||||
const isRoot = event.schedulerId === ROOT_SCHEDULER_ID;
|
||||
|
||||
// Update output timer for UI spinners (Side Effect)
|
||||
const hasExecuting = event.toolCalls.some(
|
||||
(tc) =>
|
||||
tc.status === CoreToolCallStatus.Executing ||
|
||||
((tc.status === CoreToolCallStatus.Success ||
|
||||
tc.status === CoreToolCallStatus.Error) &&
|
||||
'tailToolCallRequest' in tc &&
|
||||
tc.tailToolCallRequest != null),
|
||||
);
|
||||
|
||||
if (hasExecuting) {
|
||||
setLastToolOutputTime(Date.now());
|
||||
}
|
||||
|
||||
setToolCallsMap((prev) => {
|
||||
const prevCalls = prev[event.schedulerId] ?? [];
|
||||
const prevCallIds = new Set(prevCalls.map((tc) => tc.request.callId));
|
||||
@@ -151,20 +165,6 @@ export function useToolScheduler(
|
||||
[event.schedulerId]: adapted,
|
||||
};
|
||||
});
|
||||
|
||||
// Update output timer for UI spinners (Side Effect)
|
||||
const hasExecuting = event.toolCalls.some(
|
||||
(tc) =>
|
||||
tc.status === CoreToolCallStatus.Executing ||
|
||||
((tc.status === CoreToolCallStatus.Success ||
|
||||
tc.status === CoreToolCallStatus.Error) &&
|
||||
'tailToolCallRequest' in tc &&
|
||||
tc.tailToolCallRequest != null),
|
||||
);
|
||||
|
||||
if (hasExecuting) {
|
||||
setLastToolOutputTime(Date.now());
|
||||
}
|
||||
};
|
||||
|
||||
messageBus.subscribe(MessageBusType.TOOL_CALLS_UPDATE, handler);
|
||||
|
||||
@@ -19,6 +19,9 @@ export const CACHE_EFFICIENCY_MEDIUM = 15;
|
||||
export const QUOTA_THRESHOLD_HIGH = 20;
|
||||
export const QUOTA_THRESHOLD_MEDIUM = 5;
|
||||
|
||||
export const QUOTA_USED_WARNING_THRESHOLD = 80;
|
||||
export const QUOTA_USED_CRITICAL_THRESHOLD = 95;
|
||||
|
||||
// --- Color Logic ---
|
||||
export const getStatusColor = (
|
||||
value: number,
|
||||
@@ -36,3 +39,19 @@ export const getStatusColor = (
|
||||
}
|
||||
return options.defaultColor ?? theme.status.error;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the status color based on "used" percentage (where higher is worse).
|
||||
*/
|
||||
export const getUsedStatusColor = (
|
||||
usedPercentage: number,
|
||||
thresholds: { warning: number; critical: number },
|
||||
) => {
|
||||
if (usedPercentage >= thresholds.critical) {
|
||||
return theme.status.error;
|
||||
}
|
||||
if (usedPercentage >= thresholds.warning) {
|
||||
return theme.status.warning;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -10,9 +10,45 @@ import {
|
||||
formatBytes,
|
||||
formatTimeAgo,
|
||||
stripReferenceContent,
|
||||
formatResetTime,
|
||||
} from './formatters.js';
|
||||
|
||||
describe('formatters', () => {
|
||||
describe('formatResetTime', () => {
|
||||
const NOW = new Date('2025-01-01T12:00:00Z');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should format full time correctly', () => {
|
||||
const resetTime = new Date(NOW.getTime() + 90 * 60 * 1000).toISOString(); // 1h 30m
|
||||
const result = formatResetTime(resetTime);
|
||||
expect(result).toMatch(/1 hour 30 minutes at \d{1,2}:\d{2} [AP]M/);
|
||||
});
|
||||
|
||||
it('should format terse time correctly', () => {
|
||||
const resetTime = new Date(NOW.getTime() + 90 * 60 * 1000).toISOString(); // 1h 30m
|
||||
expect(formatResetTime(resetTime, 'terse')).toBe('1h 30m');
|
||||
});
|
||||
|
||||
it('should format column time correctly', () => {
|
||||
const resetTime = new Date(NOW.getTime() + 90 * 60 * 1000).toISOString(); // 1h 30m
|
||||
const result = formatResetTime(resetTime, 'column');
|
||||
expect(result).toMatch(/\d{1,2}:\d{2} [AP]M \(1h 30m\)/);
|
||||
});
|
||||
|
||||
it('should handle zero or negative diff by returning empty string', () => {
|
||||
const resetTime = new Date(NOW.getTime() - 1000).toISOString();
|
||||
expect(formatResetTime(resetTime)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('should format bytes into KB', () => {
|
||||
expect(formatBytes(12345)).toBe('12.1 KB');
|
||||
|
||||
@@ -98,26 +98,58 @@ export function stripReferenceContent(text: string): string {
|
||||
return text.replace(pattern, '').trim();
|
||||
}
|
||||
|
||||
export const formatResetTime = (resetTime: string): string => {
|
||||
const diff = new Date(resetTime).getTime() - Date.now();
|
||||
export const formatResetTime = (
|
||||
resetTime: string | undefined,
|
||||
format: 'terse' | 'column' | 'full' = 'full',
|
||||
): string => {
|
||||
if (!resetTime) return '';
|
||||
const resetDate = new Date(resetTime);
|
||||
if (isNaN(resetDate.getTime())) return '';
|
||||
|
||||
const diff = resetDate.getTime() - Date.now();
|
||||
if (diff <= 0) return '';
|
||||
|
||||
const totalMinutes = Math.ceil(diff / (1000 * 60));
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
const fmt = (val: number, unit: 'hour' | 'minute') =>
|
||||
new Intl.NumberFormat('en', {
|
||||
style: 'unit',
|
||||
unit,
|
||||
unitDisplay: 'narrow',
|
||||
}).format(val);
|
||||
const isTerse = format === 'terse';
|
||||
const isColumn = format === 'column';
|
||||
|
||||
if (hours > 0 && minutes > 0) {
|
||||
return `resets in ${fmt(hours, 'hour')} ${fmt(minutes, 'minute')}`;
|
||||
} else if (hours > 0) {
|
||||
return `resets in ${fmt(hours, 'hour')}`;
|
||||
if (isTerse || isColumn) {
|
||||
const hoursStr = hours > 0 ? `${hours}h` : '';
|
||||
const minutesStr = minutes > 0 ? `${minutes}m` : '';
|
||||
const duration =
|
||||
hoursStr && minutesStr
|
||||
? `${hoursStr} ${minutesStr}`
|
||||
: hoursStr || minutesStr;
|
||||
|
||||
if (isColumn) {
|
||||
const timeStr = new Intl.DateTimeFormat('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
}).format(resetDate);
|
||||
return duration ? `${timeStr} (${duration})` : timeStr;
|
||||
}
|
||||
|
||||
return duration;
|
||||
}
|
||||
|
||||
return `resets in ${fmt(minutes, 'minute')}`;
|
||||
let duration = '';
|
||||
if (hours > 0) {
|
||||
duration = `${hours} hour${hours > 1 ? 's' : ''}`;
|
||||
if (minutes > 0) {
|
||||
duration += ` ${minutes} minute${minutes > 1 ? 's' : ''}`;
|
||||
}
|
||||
} else {
|
||||
duration = `${minutes} minute${minutes > 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
const timeStr = new Intl.DateTimeFormat('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
timeZoneName: 'short',
|
||||
}).format(resetDate);
|
||||
|
||||
return `${duration} at ${timeStr}`;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,13 @@ export const TOOL_RESULT_ASB_RESERVED_LINE_COUNT = 6;
|
||||
export const TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT = 2;
|
||||
export const TOOL_RESULT_MIN_LINES_SHOWN = 2;
|
||||
|
||||
/**
|
||||
* The vertical space (in lines) consumed by the shell UI elements
|
||||
* (1 line for the shell title/header and 2 lines for the top and bottom borders).
|
||||
*/
|
||||
export const SHELL_CONTENT_OVERHEAD =
|
||||
TOOL_RESULT_STATIC_HEIGHT + TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT;
|
||||
|
||||
/**
|
||||
* Calculates the final height available for the content of a tool result display.
|
||||
*
|
||||
@@ -88,7 +95,9 @@ export function calculateShellMaxLines(options: {
|
||||
|
||||
// 2. Handle cases where height is unknown (Standard mode history).
|
||||
if (availableTerminalHeight === undefined) {
|
||||
return isAlternateBuffer ? ACTIVE_SHELL_MAX_LINES : undefined;
|
||||
return isAlternateBuffer
|
||||
? ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2);
|
||||
@@ -103,8 +112,8 @@ export function calculateShellMaxLines(options: {
|
||||
// 4. Fall back to process-based constants.
|
||||
const isExecuting = status === CoreToolCallStatus.Executing;
|
||||
const shellMaxLinesLimit = isExecuting
|
||||
? ACTIVE_SHELL_MAX_LINES
|
||||
: COMPLETED_SHELL_MAX_LINES;
|
||||
? ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD
|
||||
: COMPLETED_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD;
|
||||
|
||||
return Math.min(maxLinesBasedOnHeight, shellMaxLinesLimit);
|
||||
}
|
||||
|
||||
@@ -494,9 +494,10 @@ export class ActivityLogger extends EventEmitter {
|
||||
|
||||
req.write = function (chunk: string | Uint8Array, ...etc: unknown[]) {
|
||||
if (chunk) {
|
||||
const arg0 = etc[0];
|
||||
const encoding =
|
||||
typeof etc[0] === 'string' && Buffer.isEncoding(etc[0])
|
||||
? etc[0]
|
||||
typeof arg0 === 'string' && Buffer.isEncoding(arg0)
|
||||
? arg0
|
||||
: undefined;
|
||||
requestChunks.push(
|
||||
Buffer.isBuffer(chunk)
|
||||
@@ -519,9 +520,10 @@ export class ActivityLogger extends EventEmitter {
|
||||
) {
|
||||
const chunk = typeof chunkOrCb === 'function' ? undefined : chunkOrCb;
|
||||
if (chunk) {
|
||||
const arg0 = etc[0];
|
||||
const encoding =
|
||||
typeof etc[0] === 'string' && Buffer.isEncoding(etc[0])
|
||||
? etc[0]
|
||||
typeof arg0 === 'string' && Buffer.isEncoding(arg0)
|
||||
? arg0
|
||||
: undefined;
|
||||
requestChunks.push(
|
||||
Buffer.isBuffer(chunk)
|
||||
|
||||
@@ -9,11 +9,14 @@ import { EventEmitter } from 'node:events';
|
||||
export enum TransientMessageType {
|
||||
Warning = 'warning',
|
||||
Hint = 'hint',
|
||||
Error = 'error',
|
||||
Accent = 'accent',
|
||||
}
|
||||
|
||||
export interface TransientMessagePayload {
|
||||
message: string;
|
||||
type: TransientMessageType;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export enum AppEvent {
|
||||
|
||||
@@ -119,6 +119,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
|
||||
|
||||
if (
|
||||
activity.type === 'THOUGHT_CHUNK' &&
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof activity.data['text'] === 'string'
|
||||
) {
|
||||
updateOutput(`🌐💭 ${activity.data['text']}`);
|
||||
|
||||
@@ -356,6 +356,7 @@ class TypeTextDeclarativeTool extends DeclarativeTool<
|
||||
params: Record<string, unknown>,
|
||||
): ToolInvocation<Record<string, unknown>, ToolResult> {
|
||||
const submitKey =
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof params['submitKey'] === 'string' && params['submitKey']
|
||||
? params['submitKey']
|
||||
: undefined;
|
||||
|
||||
@@ -4,13 +4,22 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { GeneralistAgent } from './generalist-agent.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
import type { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import type { AgentRegistry } from './registry.js';
|
||||
|
||||
describe('GeneralistAgent', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('GEMINI_SYSTEM_MD', '');
|
||||
vi.stubEnv('GEMINI_WRITE_SYSTEM_MD', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should create a valid generalist agent definition', () => {
|
||||
const config = makeFakeConfig();
|
||||
vi.spyOn(config, 'getToolRegistry').mockReturnValue({
|
||||
|
||||
@@ -136,7 +136,7 @@ describe('memory commands', () => {
|
||||
if (result.type === 'message') {
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toBe(
|
||||
'Memory refreshed successfully. Loaded 33 characters from 2 file(s).',
|
||||
'Memory reloaded successfully. Loaded 33 characters from 2 file(s)',
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -153,7 +153,7 @@ describe('memory commands', () => {
|
||||
if (result.type === 'message') {
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toBe(
|
||||
'Memory refreshed successfully. No memory content found.',
|
||||
'Memory reloaded successfully. No memory content found',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -64,9 +64,9 @@ export async function refreshMemory(
|
||||
let content: string;
|
||||
|
||||
if (memoryContent.length > 0) {
|
||||
content = `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`;
|
||||
content = `Memory reloaded successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s)`;
|
||||
} else {
|
||||
content = 'Memory refreshed successfully. No memory content found.';
|
||||
content = 'Memory reloaded successfully. No memory content found';
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -355,6 +355,7 @@ export class HookAggregator {
|
||||
// Extract additionalContext from various hook types
|
||||
if (
|
||||
'additionalContext' in specific &&
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof specific['additionalContext'] === 'string'
|
||||
) {
|
||||
contexts.push(specific['additionalContext']);
|
||||
|
||||
@@ -3107,7 +3107,6 @@ describe('PolicyEngine', () => {
|
||||
expect(checkers[0].checker.name).toBe('c2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool Annotations', () => {
|
||||
it('should match tools by semantic annotations', async () => {
|
||||
engine = new PolicyEngine({
|
||||
@@ -3171,7 +3170,6 @@ describe('PolicyEngine', () => {
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook checkers', () => {
|
||||
it('should add and retrieve hook checkers in priority order', () => {
|
||||
engine.addHookChecker({
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { PromptProvider } from './promptProvider.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
@@ -35,6 +35,9 @@ describe('PromptProvider', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.stubEnv('GEMINI_SYSTEM_MD', '');
|
||||
vi.stubEnv('GEMINI_WRITE_SYSTEM_MD', '');
|
||||
|
||||
mockConfig = {
|
||||
getToolRegistry: vi.fn().mockReturnValue({
|
||||
getAllToolNames: vi.fn().mockReturnValue([]),
|
||||
@@ -60,6 +63,10 @@ describe('PromptProvider', () => {
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should handle multiple context filenames in the system prompt', () => {
|
||||
vi.mocked(getAllGeminiMdFilenames).mockReturnValue([
|
||||
DEFAULT_CONTEXT_FILENAME,
|
||||
|
||||
@@ -132,7 +132,11 @@ export class FolderTrustDiscoveryService {
|
||||
for (const event of Object.values(hooksConfig)) {
|
||||
if (!Array.isArray(event)) continue;
|
||||
for (const hook of event) {
|
||||
if (this.isRecord(hook) && typeof hook['command'] === 'string') {
|
||||
if (
|
||||
this.isRecord(hook) &&
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof hook['command'] === 'string'
|
||||
) {
|
||||
hooks.add(hook['command']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,11 +156,13 @@ async function truncateHistoryToBudget(
|
||||
} else if (responseObj && typeof responseObj === 'object') {
|
||||
if (
|
||||
'output' in responseObj &&
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof responseObj['output'] === 'string'
|
||||
) {
|
||||
contentStr = responseObj['output'];
|
||||
} else if (
|
||||
'content' in responseObj &&
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof responseObj['content'] === 'string'
|
||||
) {
|
||||
contentStr = responseObj['content'];
|
||||
|
||||
@@ -579,10 +579,12 @@ export class LoopDetectionService {
|
||||
}
|
||||
|
||||
const flashConfidence =
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof flashResult['unproductive_state_confidence'] === 'number'
|
||||
? flashResult['unproductive_state_confidence']
|
||||
: 0;
|
||||
const flashAnalysis =
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof flashResult['unproductive_state_analysis'] === 'string'
|
||||
? flashResult['unproductive_state_analysis']
|
||||
: '';
|
||||
@@ -628,11 +630,13 @@ export class LoopDetectionService {
|
||||
|
||||
const mainModelConfidence =
|
||||
mainModelResult &&
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof mainModelResult['unproductive_state_confidence'] === 'number'
|
||||
? mainModelResult['unproductive_state_confidence']
|
||||
: 0;
|
||||
const mainModelAnalysis =
|
||||
mainModelResult &&
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof mainModelResult['unproductive_state_analysis'] === 'string'
|
||||
? mainModelResult['unproductive_state_analysis']
|
||||
: undefined;
|
||||
@@ -681,6 +685,7 @@ export class LoopDetectionService {
|
||||
|
||||
if (
|
||||
result &&
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof result['unproductive_state_confidence'] === 'number'
|
||||
) {
|
||||
return result;
|
||||
|
||||
@@ -63,6 +63,7 @@ function getStringReferences(parts: AnyPart[]): StringReference[] {
|
||||
});
|
||||
}
|
||||
} else if (part instanceof GenericPart) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
if (part.type === 'executableCode' && typeof part['code'] === 'string') {
|
||||
refs.push({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
@@ -73,6 +74,7 @@ function getStringReferences(parts: AnyPart[]): StringReference[] {
|
||||
});
|
||||
} else if (
|
||||
part.type === 'codeExecutionResult' &&
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof part['output'] === 'string'
|
||||
) {
|
||||
refs.push({
|
||||
|
||||
@@ -2053,7 +2053,6 @@ describe('mcp-client', () => {
|
||||
expect(callArgs.env!['GEMINI_CLI_EXT_VAR']).toBe('ext-value');
|
||||
expect(callArgs.env!['RESOLVED_VAR']).toBe('ext-value');
|
||||
});
|
||||
|
||||
it('should expand environment variables in mcpServerConfig.env and not redact them', async () => {
|
||||
const mockedTransport = vi
|
||||
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
|
||||
|
||||
@@ -107,7 +107,7 @@ export function isMcpToolAnnotation(
|
||||
return (
|
||||
typeof annotation === 'object' &&
|
||||
annotation !== null &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, no-restricted-syntax
|
||||
typeof (annotation as Record<string, unknown>)['_serverName'] === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ Return ONLY the corrected string in the specified JSON format with the key 'corr
|
||||
|
||||
if (
|
||||
result &&
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof result['corrected_string_escaping'] === 'string' &&
|
||||
result['corrected_string_escaping'].length > 0
|
||||
) {
|
||||
|
||||
@@ -209,6 +209,7 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null {
|
||||
}
|
||||
// Basic structural check before casting.
|
||||
// Since the proto definitions are loose, we primarily rely on @type presence.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
if (typeof detailObj['@type'] === 'string') {
|
||||
// We can just cast it; the consumer will have to switch on @type
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
||||
@@ -361,19 +361,24 @@ async function parseTokenEndpointResponse(
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
'access_token' in data &&
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof (data as Record<string, unknown>)['access_token'] === 'string'
|
||||
) {
|
||||
const obj = data as Record<string, unknown>;
|
||||
const result: OAuthTokenResponse = {
|
||||
access_token: String(obj['access_token']),
|
||||
token_type:
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof obj['token_type'] === 'string' ? obj['token_type'] : 'Bearer',
|
||||
expires_in:
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof obj['expires_in'] === 'number' ? obj['expires_in'] : undefined,
|
||||
refresh_token:
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof obj['refresh_token'] === 'string'
|
||||
? obj['refresh_token']
|
||||
: undefined,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
scope: typeof obj['scope'] === 'string' ? obj['scope'] : undefined,
|
||||
};
|
||||
return result;
|
||||
|
||||
@@ -1222,8 +1222,8 @@
|
||||
},
|
||||
"loadMemoryFromIncludeDirectories": {
|
||||
"title": "Load Memory From Include Directories",
|
||||
"description": "Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used.",
|
||||
"markdownDescription": "Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used.\n\n- Category: `Context`\n- Requires restart: `no`\n- Default: `false`",
|
||||
"description": "Controls how /memory reload loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used.",
|
||||
"markdownDescription": "Controls how /memory reload loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used.\n\n- Category: `Context`\n- Requires restart: `no`\n- Default: `false`",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user