mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 16:20:57 -07:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f27bcc1592 | |||
| 651c1a28b8 | |||
| e062f0d09a | |||
| 79ea865790 |
@@ -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 -- --coverage.enabled=false
|
||||
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:scripts
|
||||
fi
|
||||
|
||||
|
||||
@@ -24,21 +24,6 @@ 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 reload`**: Forces a re-scan and reload of all `GEMINI.md` files
|
||||
- **`/memory refresh`**: 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.
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
# Notifications (experimental)
|
||||
|
||||
Gemini CLI can send system notifications to alert you when a session completes
|
||||
or when it needs your attention, such as when it's waiting for you to approve a
|
||||
tool call.
|
||||
|
||||
> **Note:** This is a preview feature currently under active development.
|
||||
> Preview features may be available on the **Preview** channel or may need to be
|
||||
> enabled under `/settings`.
|
||||
|
||||
Notifications are particularly useful when running long-running tasks or using
|
||||
[Plan Mode](./plan-mode.md), letting you switch to other windows while Gemini
|
||||
CLI works in the background.
|
||||
|
||||
## Requirements
|
||||
|
||||
Currently, system notifications are only supported on macOS.
|
||||
|
||||
### Terminal support
|
||||
|
||||
The CLI uses the OSC 9 terminal escape sequence to trigger system notifications.
|
||||
This is supported by several modern terminal emulators. If your terminal does
|
||||
not support OSC 9 notifications, Gemini CLI falls back to a system alert sound
|
||||
to get your attention.
|
||||
|
||||
## Enable notifications
|
||||
|
||||
Notifications are disabled by default. You can enable them using the `/settings`
|
||||
command or by updating your `settings.json` file.
|
||||
|
||||
1. Open the settings dialog by typing `/settings` in an interactive session.
|
||||
2. Navigate to the **General** category.
|
||||
3. Toggle the **Enable Notifications** setting to **On**.
|
||||
|
||||
Alternatively, add the following to your `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"general": {
|
||||
"enableNotifications": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Types of notifications
|
||||
|
||||
Gemini CLI sends notifications for the following events:
|
||||
|
||||
- **Action required:** Triggered when the model is waiting for user input or
|
||||
tool approval. This helps you know when the CLI has paused and needs you to
|
||||
intervene.
|
||||
- **Session complete:** Triggered when a session finishes successfully. This is
|
||||
useful for tracking the completion of automated tasks.
|
||||
|
||||
## Next steps
|
||||
|
||||
- Start planning with [Plan Mode](./plan-mode.md).
|
||||
- Configure your experience with other [settings](./settings.md).
|
||||
@@ -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 reload 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 refresh 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 reload` to force the CLI to re-query the server
|
||||
for its capabilities.
|
||||
- **Tools not found?** Run `/mcp refresh` 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 reload`
|
||||
**Command:** `/memory refresh`
|
||||
|
||||
## 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 reload`: Reloads the agent registry. Use this after adding or
|
||||
- `/agents refresh`: 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.
|
||||
|
||||
@@ -175,6 +175,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Minimum retention period (safety limit, defaults to "1d")
|
||||
- **Default:** `"1d"`
|
||||
|
||||
- **`general.sessionRetention.warningAcknowledged`** (boolean):
|
||||
- **Description:** Whether the user has acknowledged the session retention
|
||||
warning
|
||||
- **Default:** `false`
|
||||
|
||||
#### `output`
|
||||
|
||||
- **`output.format`** (enum):
|
||||
@@ -719,7 +724,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `[]`
|
||||
|
||||
- **`context.loadMemoryFromIncludeDirectories`** (boolean):
|
||||
- **Description:** Controls how /memory reload loads GEMINI.md files. When
|
||||
- **Description:** Controls how /memory refresh loads GEMINI.md files. When
|
||||
true, include directories are scanned; when false, only the current
|
||||
directory is used.
|
||||
- **Default:** `false`
|
||||
@@ -1705,7 +1710,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 `reload`).
|
||||
the `/memory` command and its sub-commands (`show` and `refresh`).
|
||||
|
||||
By understanding and utilizing these configuration layers and the hierarchical
|
||||
nature of context files, you can effectively manage the AI's memory and tailor
|
||||
|
||||
@@ -106,11 +106,6 @@
|
||||
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
|
||||
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
|
||||
{ "label": "Model selection", "slug": "docs/cli/model" },
|
||||
{
|
||||
"label": "Notifications",
|
||||
"badge": "🔬",
|
||||
"slug": "docs/cli/notifications"
|
||||
},
|
||||
{ "label": "Plan mode", "badge": "🔬", "slug": "docs/cli/plan-mode" },
|
||||
{
|
||||
"label": "Subagents",
|
||||
|
||||
+1
-11
@@ -132,16 +132,7 @@ export default tseslint.config(
|
||||
'no-cond-assign': 'error',
|
||||
'no-debugger': 'error',
|
||||
'no-duplicate-case': 'error',
|
||||
'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-restricted-syntax': ['error', ...commonRestrictedSyntaxRules],
|
||||
'no-unsafe-finally': 'error',
|
||||
'no-unused-expressions': 'off', // Disable base rule
|
||||
'@typescript-eslint/no-unused-expressions': [
|
||||
@@ -272,7 +263,6 @@ export default tseslint.config(
|
||||
...vitest.configs.recommended.rules,
|
||||
'vitest/expect-expect': 'off',
|
||||
'vitest/no-commented-out-tests': 'off',
|
||||
'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -71,30 +71,4 @@ describe('interactive_commands', () => {
|
||||
).toMatch(/(?:^|\s)(--yes|-y|--template\s+\S+)(?:\s|$|\\|")/);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates that the agent does not hang when creating a vite app.
|
||||
*/
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: 'should not hang when creating a vite app',
|
||||
prompt: 'create a hello world app with vite',
|
||||
files: {
|
||||
//'.npmrc': 'cache=./.npm-cache',
|
||||
},
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForToolCall('run_shell_command');
|
||||
const logs = rig.readToolLogs();
|
||||
const viteCall = logs.find(
|
||||
(l) =>
|
||||
l.toolRequest.name === 'run_shell_command' &&
|
||||
l.toolRequest.args.toLowerCase().includes('vite'),
|
||||
);
|
||||
|
||||
expect(viteCall, 'Agent should have called vite').toBeDefined();
|
||||
expect(
|
||||
viteCall?.toolRequest.success,
|
||||
'Vite tool call should finish successfully without hanging',
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -832,9 +832,7 @@ 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;
|
||||
|
||||
@@ -358,7 +358,10 @@ export class GeminiAgent {
|
||||
config.setFileSystemService(acpFileSystemService);
|
||||
}
|
||||
|
||||
const clientHistory = convertSessionToClientHistory(sessionData.messages);
|
||||
const clientHistory = convertSessionToClientHistory(
|
||||
sessionData.messages,
|
||||
sessionData.lastCompressionIndex,
|
||||
);
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
await geminiClient.initialize();
|
||||
|
||||
@@ -79,7 +79,6 @@ 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,
|
||||
@@ -94,7 +93,6 @@ 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'];
|
||||
}
|
||||
@@ -142,7 +140,6 @@ 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']);
|
||||
|
||||
@@ -671,6 +671,13 @@ describe('parseArguments', () => {
|
||||
const argv = await parseArguments(settings);
|
||||
expect(argv.isCommand).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly parse the --forever flag', async () => {
|
||||
process.argv = ['node', 'script.js', '--forever'];
|
||||
const settings = createTestMergedSettings({});
|
||||
const argv = await parseArguments(settings);
|
||||
expect(argv.forever).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import yargs from 'yargs/yargs';
|
||||
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import process from 'node:process';
|
||||
import { mcpCommand } from '../commands/mcp.js';
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
type HookDefinition,
|
||||
type HookEventName,
|
||||
type OutputFormat,
|
||||
type SisyphusModeSettings,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
type Settings,
|
||||
@@ -67,6 +69,7 @@ export interface CliArgs {
|
||||
query: string | undefined;
|
||||
model: string | undefined;
|
||||
sandbox: boolean | string | undefined;
|
||||
forever: boolean | undefined;
|
||||
debug: boolean | undefined;
|
||||
prompt: string | undefined;
|
||||
promptInteractive: string | undefined;
|
||||
@@ -143,7 +146,12 @@ export async function parseArguments(
|
||||
type: 'boolean',
|
||||
description: 'Run in sandbox?',
|
||||
})
|
||||
|
||||
.option('forever', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Enable forever (long-running agent) mode. Uses GEMINI.md frontmatter for sisyphus engine config.',
|
||||
default: false,
|
||||
})
|
||||
.option('yolo', {
|
||||
alias: 'y',
|
||||
type: 'boolean',
|
||||
@@ -488,6 +496,68 @@ export async function loadCliConfig(
|
||||
|
||||
const experimentalJitContext = settings.experimental?.jitContext ?? false;
|
||||
|
||||
let sisyphusMode: SisyphusModeSettings | undefined;
|
||||
const isForeverMode = argv.forever ?? false;
|
||||
|
||||
if (isForeverMode) {
|
||||
try {
|
||||
const yaml = await import('js-yaml');
|
||||
const fsPromises = await import('node:fs/promises');
|
||||
const path = await import('node:path');
|
||||
const { FRONTMATTER_REGEX } = await import('@google/gemini-cli-core');
|
||||
const { GEMINI_DIR } = await import('@google/gemini-cli-core');
|
||||
const { DEFAULT_CONTEXT_FILENAME } = await import(
|
||||
'@google/gemini-cli-core'
|
||||
);
|
||||
|
||||
const geminiMdPath = path.default.join(
|
||||
cwd,
|
||||
GEMINI_DIR,
|
||||
DEFAULT_CONTEXT_FILENAME,
|
||||
);
|
||||
const mdContent = await fsPromises.default.readFile(
|
||||
geminiMdPath,
|
||||
'utf-8',
|
||||
);
|
||||
const match = mdContent.match(FRONTMATTER_REGEX);
|
||||
|
||||
if (match) {
|
||||
const parsed = yaml.default.load(match[1]);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const frontmatter = parsed as Record<string, unknown>;
|
||||
if (frontmatter['sisyphus']) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const sisyphusSettings = frontmatter['sisyphus'] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
sisyphusMode = {
|
||||
enabled:
|
||||
typeof sisyphusSettings['enabled'] === 'boolean'
|
||||
? sisyphusSettings['enabled']
|
||||
: false,
|
||||
idleTimeout:
|
||||
typeof sisyphusSettings['idleTimeout'] === 'number'
|
||||
? sisyphusSettings['idleTimeout']
|
||||
: undefined,
|
||||
prompt:
|
||||
typeof sisyphusSettings['prompt'] === 'string'
|
||||
? sisyphusSettings['prompt']
|
||||
: undefined,
|
||||
a2aPort:
|
||||
typeof sisyphusSettings['a2aPort'] === 'number'
|
||||
? sisyphusSettings['a2aPort']
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignored
|
||||
}
|
||||
}
|
||||
|
||||
let memoryContent: string | HierarchicalMemory = '';
|
||||
let fileCount = 0;
|
||||
let filePaths: string[] = [];
|
||||
@@ -511,8 +581,11 @@ export async function loadCliConfig(
|
||||
filePaths = result.filePaths;
|
||||
}
|
||||
|
||||
const question = argv.promptInteractive || argv.prompt || '';
|
||||
|
||||
const question =
|
||||
argv.promptInteractive ||
|
||||
argv.prompt ||
|
||||
process.env['GEMINI_CLI_INITIAL_PROMPT'] ||
|
||||
'';
|
||||
// Determine approval mode with backward compatibility
|
||||
let approvalMode: ApprovalMode;
|
||||
const rawApprovalMode =
|
||||
@@ -605,7 +678,8 @@ export async function loadCliConfig(
|
||||
!!argv.acp ||
|
||||
!!argv.experimentalAcp ||
|
||||
(!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) &&
|
||||
!argv.isCommand);
|
||||
!argv.isCommand) ||
|
||||
!!argv.forever;
|
||||
|
||||
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
|
||||
|
||||
@@ -773,6 +847,8 @@ export async function loadCliConfig(
|
||||
? settings.general.plan
|
||||
: (extensionPlanSettings ?? settings.general?.plan),
|
||||
enableEventDrivenScheduler: true,
|
||||
isForeverMode,
|
||||
sisyphusMode,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
experimentalJitContext: settings.experimental?.jitContext,
|
||||
|
||||
@@ -183,6 +183,7 @@ 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 });
|
||||
|
||||
@@ -185,6 +185,9 @@ export interface SessionRetentionSettings {
|
||||
|
||||
/** Minimum retention period (safety limit, defaults to "1d") */
|
||||
minRetention?: string;
|
||||
|
||||
/** Whether the user has acknowledged the session retention warning */
|
||||
warningAcknowledged?: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsError {
|
||||
|
||||
@@ -376,6 +376,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: `Minimum retention period (safety limit, defaults to "${DEFAULT_MIN_RETENTION}")`,
|
||||
showInDialog: false,
|
||||
},
|
||||
warningAcknowledged: {
|
||||
type: 'boolean',
|
||||
label: 'Warning Acknowledged',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false as boolean,
|
||||
description:
|
||||
'Whether the user has acknowledged the session retention warning',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
description: 'Settings for automatic session cleanup.',
|
||||
},
|
||||
@@ -1169,7 +1179,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: oneLine`
|
||||
Controls how /memory reload loads GEMINI.md files.
|
||||
Controls how /memory refresh loads GEMINI.md files.
|
||||
When true, include directories are scanned; when false, only the current directory is used.
|
||||
`,
|
||||
showInDialog: true,
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import { writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import os from 'node:os';
|
||||
import crypto from 'node:crypto';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
|
||||
// --- A2A Task management ---
|
||||
|
||||
interface A2AResponseMessage {
|
||||
kind: 'message';
|
||||
role: 'agent';
|
||||
parts: Array<{ kind: 'text'; text: string }>;
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
interface A2ATask {
|
||||
id: string;
|
||||
contextId: string;
|
||||
status: {
|
||||
state: 'submitted' | 'working' | 'completed' | 'failed';
|
||||
timestamp: string;
|
||||
message?: A2AResponseMessage;
|
||||
};
|
||||
}
|
||||
|
||||
const tasks = new Map<string, A2ATask>();
|
||||
|
||||
const TASK_CLEANUP_DELAY_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const DEFAULT_BLOCKING_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
interface ResponseWaiter {
|
||||
taskId: string;
|
||||
resolve: (text: string) => void;
|
||||
}
|
||||
|
||||
const responseWaiters: ResponseWaiter[] = [];
|
||||
|
||||
// Queue for unsolicited responses (e.g. Sisyphus auto-resume output)
|
||||
const unsolicitedResponses: string[] = [];
|
||||
|
||||
/**
|
||||
* Called by AppContainer when streaming transitions from non-Idle to Idle.
|
||||
* If there's a pending A2A task, resolves it. Otherwise queues as unsolicited.
|
||||
*/
|
||||
export function notifyResponse(responseText: string): void {
|
||||
if (!responseText) return;
|
||||
|
||||
const waiter = responseWaiters.shift();
|
||||
if (!waiter) {
|
||||
// No A2A task waiting — queue as unsolicited (Sisyphus, etc.)
|
||||
unsolicitedResponses.push(responseText);
|
||||
return;
|
||||
}
|
||||
|
||||
const task = tasks.get(waiter.taskId);
|
||||
if (task) {
|
||||
task.status = {
|
||||
state: 'completed',
|
||||
timestamp: new Date().toISOString(),
|
||||
message: {
|
||||
kind: 'message',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: responseText }],
|
||||
messageId: crypto.randomUUID(),
|
||||
},
|
||||
};
|
||||
scheduleTaskCleanup(task.id);
|
||||
}
|
||||
|
||||
waiter.resolve(responseText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain all unsolicited responses (from Sisyphus auto-resume, etc.).
|
||||
*/
|
||||
export function drainUnsolicitedResponses(): string[] {
|
||||
return unsolicitedResponses.splice(0, unsolicitedResponses.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there are any in-flight tasks waiting for a response.
|
||||
*/
|
||||
export function hasPendingTasks(): boolean {
|
||||
return responseWaiters.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when streaming starts (Idle -> non-Idle) to mark the oldest
|
||||
* submitted task as "working".
|
||||
*/
|
||||
export function markTasksWorking(): void {
|
||||
const waiter = responseWaiters[0];
|
||||
if (!waiter) return;
|
||||
const task = tasks.get(waiter.taskId);
|
||||
if (task && task.status.state === 'submitted') {
|
||||
task.status = {
|
||||
state: 'working',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleTaskCleanup(taskId: string): void {
|
||||
setTimeout(() => {
|
||||
tasks.delete(taskId);
|
||||
}, TASK_CLEANUP_DELAY_MS);
|
||||
}
|
||||
|
||||
function createTask(): A2ATask {
|
||||
const task: A2ATask = {
|
||||
id: crypto.randomUUID(),
|
||||
contextId: `session-${process.pid}`,
|
||||
status: {
|
||||
state: 'submitted',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
tasks.set(task.id, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
function formatTaskResult(task: A2ATask): object {
|
||||
return {
|
||||
kind: 'task',
|
||||
id: task.id,
|
||||
contextId: task.contextId,
|
||||
status: task.status,
|
||||
};
|
||||
}
|
||||
|
||||
// --- JSON-RPC helpers ---
|
||||
|
||||
interface JsonRpcRequest {
|
||||
jsonrpc?: string;
|
||||
id?: string | number | null;
|
||||
method?: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function jsonRpcSuccess(id: string | number | null, result: object): object {
|
||||
return { jsonrpc: '2.0', id, result };
|
||||
}
|
||||
|
||||
function jsonRpcError(
|
||||
id: string | number | null,
|
||||
code: number,
|
||||
message: string,
|
||||
): object {
|
||||
return { jsonrpc: '2.0', id, error: { code, message } };
|
||||
}
|
||||
|
||||
// --- HTTP utilities ---
|
||||
|
||||
function getSessionsDir(): string {
|
||||
return join(os.homedir(), '.gemini', 'sessions');
|
||||
}
|
||||
|
||||
function getPortFilePath(): string {
|
||||
return join(getSessionsDir(), `interactive-${process.pid}.port`);
|
||||
}
|
||||
|
||||
function buildAgentCard(port: number): object {
|
||||
return {
|
||||
name: 'Gemini CLI Interactive Session',
|
||||
url: `http://localhost:${port}/`,
|
||||
protocolVersion: '0.3.0',
|
||||
provider: { organization: 'Google', url: 'https://google.com' },
|
||||
capabilities: { streaming: false, pushNotifications: false },
|
||||
defaultInputModes: ['text'],
|
||||
defaultOutputModes: ['text'],
|
||||
skills: [
|
||||
{
|
||||
id: 'interactive_session',
|
||||
name: 'Interactive Session',
|
||||
description: 'Send messages to the live interactive Gemini CLI session',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
interface A2AMessagePart {
|
||||
kind?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
function extractTextFromParts(
|
||||
parts: A2AMessagePart[] | undefined,
|
||||
): string | null {
|
||||
if (!Array.isArray(parts)) {
|
||||
return null;
|
||||
}
|
||||
const texts: string[] = [];
|
||||
for (const part of parts) {
|
||||
if (part.kind === 'text' && typeof part.text === 'string') {
|
||||
texts.push(part.text);
|
||||
}
|
||||
}
|
||||
return texts.length > 0 ? texts.join('\n') : null;
|
||||
}
|
||||
|
||||
function sendJson(
|
||||
res: http.ServerResponse,
|
||||
statusCode: number,
|
||||
data: object,
|
||||
): void {
|
||||
const body = JSON.stringify(data);
|
||||
res.writeHead(statusCode, {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function readBody(req: http.IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
const maxSize = 1024 * 1024; // 1MB limit
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > maxSize) {
|
||||
req.destroy();
|
||||
reject(new Error('Request body too large'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// --- JSON-RPC request handlers ---
|
||||
|
||||
function handleMessageSend(
|
||||
rpcId: string | number | null,
|
||||
params: Record<string, unknown>,
|
||||
res: http.ServerResponse,
|
||||
): void {
|
||||
const messageVal = params['message'];
|
||||
const message =
|
||||
messageVal && typeof messageVal === 'object'
|
||||
? (messageVal as { role?: string; parts?: A2AMessagePart[] })
|
||||
: undefined;
|
||||
const text = extractTextFromParts(message?.parts);
|
||||
if (!text) {
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
jsonRpcError(
|
||||
rpcId,
|
||||
-32602,
|
||||
'Missing or empty text. Expected: params.message.parts with kind "text".',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const task = createTask();
|
||||
|
||||
// Inject message into the session
|
||||
appEvents.emit(AppEvent.ExternalMessage, text);
|
||||
|
||||
// Block until response (standard A2A message/send semantics)
|
||||
const timer = setTimeout(() => {
|
||||
const idx = responseWaiters.findIndex((w) => w.taskId === task.id);
|
||||
if (idx !== -1) {
|
||||
responseWaiters.splice(idx, 1);
|
||||
}
|
||||
task.status = {
|
||||
state: 'failed',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
scheduleTaskCleanup(task.id);
|
||||
sendJson(res, 200, jsonRpcError(rpcId, -32000, 'Request timed out'));
|
||||
}, DEFAULT_BLOCKING_TIMEOUT_MS);
|
||||
|
||||
responseWaiters.push({
|
||||
taskId: task.id,
|
||||
resolve: () => {
|
||||
clearTimeout(timer);
|
||||
// Task is already updated in notifyResponse
|
||||
const updatedTask = tasks.get(task.id);
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
jsonRpcSuccess(rpcId, formatTaskResult(updatedTask ?? task)),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleResponsesPoll(
|
||||
rpcId: string | number | null,
|
||||
res: http.ServerResponse,
|
||||
): void {
|
||||
const responses = drainUnsolicitedResponses();
|
||||
sendJson(res, 200, jsonRpcSuccess(rpcId, { responses }));
|
||||
}
|
||||
|
||||
function handleTasksGet(
|
||||
rpcId: string | number | null,
|
||||
params: Record<string, unknown>,
|
||||
res: http.ServerResponse,
|
||||
): void {
|
||||
const taskId = params['id'];
|
||||
if (typeof taskId !== 'string') {
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
jsonRpcError(rpcId, -32602, 'Missing or invalid params.id'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const task = tasks.get(taskId);
|
||||
if (!task) {
|
||||
sendJson(res, 200, jsonRpcError(rpcId, -32001, 'Task not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, 200, jsonRpcSuccess(rpcId, formatTaskResult(task)));
|
||||
}
|
||||
|
||||
// --- Server ---
|
||||
|
||||
export interface ExternalListenerResult {
|
||||
port: number;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an embedded HTTP server that accepts A2A-format JSON-RPC messages
|
||||
* and bridges them into the interactive session's message queue.
|
||||
*/
|
||||
export function startExternalListener(options?: {
|
||||
port?: number;
|
||||
}): Promise<ExternalListenerResult> {
|
||||
const port = options?.port ?? 0;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer(
|
||||
(req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
const url = new URL(req.url ?? '/', `http://localhost`);
|
||||
|
||||
// GET /.well-known/agent-card.json
|
||||
if (
|
||||
req.method === 'GET' &&
|
||||
url.pathname === '/.well-known/agent-card.json'
|
||||
) {
|
||||
const address = server.address();
|
||||
const actualPort =
|
||||
typeof address === 'object' && address ? address.port : port;
|
||||
sendJson(res, 200, buildAgentCard(actualPort));
|
||||
return;
|
||||
}
|
||||
|
||||
// POST / — JSON-RPC 2.0 routing
|
||||
if (req.method === 'POST' && url.pathname === '/') {
|
||||
readBody(req)
|
||||
.then((rawBody) => {
|
||||
let parsed: JsonRpcRequest;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
parsed = JSON.parse(rawBody) as JsonRpcRequest;
|
||||
} catch {
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
jsonRpcError(null, -32700, 'Parse error: invalid JSON'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const rpcId = parsed.id ?? null;
|
||||
const method = parsed.method;
|
||||
const params = parsed.params ?? {};
|
||||
|
||||
switch (method) {
|
||||
case 'message/send':
|
||||
handleMessageSend(rpcId, params, res);
|
||||
break;
|
||||
case 'tasks/get':
|
||||
handleTasksGet(rpcId, params, res);
|
||||
break;
|
||||
case 'responses/poll':
|
||||
handleResponsesPoll(rpcId, res);
|
||||
break;
|
||||
default:
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
jsonRpcError(
|
||||
rpcId,
|
||||
-32601,
|
||||
`Method not found: ${method ?? '(none)'}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
jsonRpcError(null, -32603, 'Failed to read request body'),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 404 for everything else
|
||||
sendJson(res, 404, { error: 'Not found' });
|
||||
},
|
||||
);
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const actualPort =
|
||||
typeof address === 'object' && address ? address.port : port;
|
||||
|
||||
// Write port file
|
||||
try {
|
||||
const sessionsDir = getSessionsDir();
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
writeFileSync(getPortFilePath(), String(actualPort), 'utf-8');
|
||||
} catch {
|
||||
// Non-fatal: port file is a convenience, not a requirement
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
server.close();
|
||||
try {
|
||||
unlinkSync(getPortFilePath());
|
||||
} catch {
|
||||
// Ignore: file may already be deleted
|
||||
}
|
||||
};
|
||||
|
||||
resolve({ port: actualPort, cleanup });
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -479,6 +479,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
promptInteractive: undefined,
|
||||
query: undefined,
|
||||
yolo: undefined,
|
||||
forever: undefined,
|
||||
approvalMode: undefined,
|
||||
policy: undefined,
|
||||
allowedMcpServerNames: undefined,
|
||||
|
||||
@@ -84,6 +84,7 @@ import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
||||
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
import { startExternalListener } from './external-listener.js';
|
||||
import { SessionError, SessionSelector } from './utils/sessionUtils.js';
|
||||
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
||||
import { MouseProvider } from './ui/contexts/MouseContext.js';
|
||||
@@ -323,6 +324,26 @@ export async function startInteractiveUI(
|
||||
registerCleanup(() => instance.unmount());
|
||||
|
||||
registerCleanup(setupTtyCheck());
|
||||
|
||||
// Auto-start A2A HTTP listener in Forever Mode
|
||||
const sisyphusMode = config.getSisyphusMode();
|
||||
if (config.getIsForeverMode()) {
|
||||
const a2aPort = sisyphusMode.a2aPort ?? 0;
|
||||
try {
|
||||
const listener = await startExternalListener({ port: a2aPort });
|
||||
registerCleanup(listener.cleanup);
|
||||
appEvents.emit(AppEvent.A2AListenerStarted, listener.port);
|
||||
coreEvents.emitFeedback(
|
||||
'info',
|
||||
`A2A endpoint listening on port ${listener.port}`,
|
||||
);
|
||||
} catch (err) {
|
||||
coreEvents.emitFeedback(
|
||||
'warning',
|
||||
`Failed to start A2A listener: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
|
||||
@@ -222,6 +222,7 @@ export async function runNonInteractive({
|
||||
await geminiClient.resumeChat(
|
||||
convertSessionToClientHistory(
|
||||
resumedSessionData.conversation.messages,
|
||||
resumedSessionData.conversation.lastCompressionIndex,
|
||||
),
|
||||
resumedSessionData,
|
||||
);
|
||||
|
||||
@@ -65,7 +65,6 @@ 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,7 +96,6 @@ function isInkRenderMetrics(
|
||||
typeof m === 'object' &&
|
||||
m !== null &&
|
||||
'output' in m &&
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof m['output'] === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,7 +232,10 @@ 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,
|
||||
@@ -332,6 +335,7 @@ describe('AppContainer State Management', () => {
|
||||
backgroundShells: new Map(),
|
||||
registerBackgroundShell: vi.fn(),
|
||||
dismissBackgroundShell: vi.fn(),
|
||||
sisyphusSecondsRemaining: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -2194,6 +2198,35 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Terminal Height Calculation', () => {
|
||||
const mockedMeasureElement = measureElement as Mock;
|
||||
const mockedUseTerminalSize = useTerminalSize as Mock;
|
||||
|
||||
it.skip('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;
|
||||
@@ -3109,6 +3142,30 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shell Interaction', () => {
|
||||
it.skip('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();
|
||||
@@ -3409,63 +3466,6 @@ describe('AppContainer State Management', () => {
|
||||
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.showIsExpandableHint).toBe(true);
|
||||
});
|
||||
|
||||
// 2. Advance half the duration
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// 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.showIsExpandableHint).toBe(true);
|
||||
});
|
||||
|
||||
// 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.showIsExpandableHint).toBe(true);
|
||||
|
||||
// 5. Advance to the end of the NEW timer
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 100);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(false);
|
||||
});
|
||||
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => {
|
||||
let unmount: () => void;
|
||||
let stdin: ReturnType<typeof renderAppContainer>['stdin'];
|
||||
@@ -3607,7 +3607,7 @@ describe('AppContainer State Management', () => {
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('DOES set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => {
|
||||
it('does NOT set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => {
|
||||
const alternateSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
const settingsWithAlternateBuffer = {
|
||||
merged: {
|
||||
@@ -3635,10 +3635,8 @@ describe('AppContainer State Management', () => {
|
||||
capturedOverflowActions.addOverflowingId('test-id');
|
||||
});
|
||||
|
||||
// Should NOW show hint because we are in Alternate Buffer Mode
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
});
|
||||
// Should NOT show hint because we are in Alternate Buffer Mode
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(false);
|
||||
|
||||
unmount!();
|
||||
});
|
||||
|
||||
@@ -126,6 +126,7 @@ 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 { notifyResponse, markTasksWorking } from '../external-listener.js';
|
||||
import { type UpdateObject } from './utils/updateCheck.js';
|
||||
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
|
||||
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
|
||||
@@ -231,6 +232,19 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
useMemoryMonitor(historyManager);
|
||||
const isAlternateBuffer = config.getUseAlternateBuffer();
|
||||
const [corgiMode, setCorgiMode] = useState(false);
|
||||
const [a2aListenerPort, setA2aListenerPort] = useState<number | null>(null);
|
||||
|
||||
// Listen for A2A listener startup to display port in status bar
|
||||
useEffect(() => {
|
||||
const handler = (port: number) => {
|
||||
setA2aListenerPort(port);
|
||||
};
|
||||
appEvents.on(AppEvent.A2AListenerStarted, handler);
|
||||
return () => {
|
||||
appEvents.off(AppEvent.A2AListenerStarted, handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [forceRerenderKey, setForceRerenderKey] = useState(0);
|
||||
const [debugMessage, setDebugMessage] = useState<string>('');
|
||||
const [quittingMessages, setQuittingMessages] = useState<
|
||||
@@ -283,18 +297,19 @@ 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.
|
||||
* By depending on overflowingIdsSize, the timer resets when *new* views
|
||||
* overflow, but avoids infinitely resetting during single-view streaming.
|
||||
* 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.
|
||||
*
|
||||
* 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) {
|
||||
if (hasOverflowState && !isAlternateBuffer) {
|
||||
triggerExpandHint(true);
|
||||
}
|
||||
}, [hasOverflowState, overflowingIdsSize, triggerExpandHint]);
|
||||
}, [hasOverflowState, isAlternateBuffer, triggerExpandHint]);
|
||||
|
||||
const [defaultBannerText, setDefaultBannerText] = useState('');
|
||||
const [warningBannerText, setWarningBannerText] = useState('');
|
||||
@@ -1009,10 +1024,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Memory reloaded successfully. ${
|
||||
text: `Memory refreshed 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(),
|
||||
@@ -1112,6 +1127,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
backgroundShells,
|
||||
dismissBackgroundShell,
|
||||
retryStatus,
|
||||
sisyphusSecondsRemaining,
|
||||
} = useGeminiStream(
|
||||
config.getGeminiClient(),
|
||||
historyManager.history,
|
||||
@@ -1203,6 +1219,53 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isMcpReady,
|
||||
});
|
||||
|
||||
// Bridge external messages from A2A HTTP listener to message queue
|
||||
useEffect(() => {
|
||||
const handler = (text: string) => {
|
||||
addMessage(text);
|
||||
};
|
||||
appEvents.on(AppEvent.ExternalMessage, handler);
|
||||
return () => {
|
||||
appEvents.off(AppEvent.ExternalMessage, handler);
|
||||
};
|
||||
}, [addMessage]);
|
||||
|
||||
// Track streaming state transitions for A2A response capture
|
||||
const prevStreamingStateRef = useRef(streamingState);
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevStreamingStateRef.current;
|
||||
prevStreamingStateRef.current = streamingState;
|
||||
|
||||
// Mark tasks as "working" when streaming starts
|
||||
if (
|
||||
prev === StreamingState.Idle &&
|
||||
streamingState !== StreamingState.Idle
|
||||
) {
|
||||
markTasksWorking();
|
||||
}
|
||||
|
||||
// Capture response when streaming ends (for A2A tasks or unsolicited output)
|
||||
if (
|
||||
prev !== StreamingState.Idle &&
|
||||
streamingState === StreamingState.Idle
|
||||
) {
|
||||
// Collect all contiguous trailing gemini items to form the full response.
|
||||
// Items can be 'gemini' or 'gemini_content' (split large messages).
|
||||
const history = historyManager.history;
|
||||
const parts: string[] = [];
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
const item = history[i];
|
||||
if (item.type !== 'gemini' && item.type !== 'gemini_content') break;
|
||||
if (typeof item.text === 'string' && item.text) {
|
||||
parts.unshift(item.text);
|
||||
}
|
||||
}
|
||||
notifyResponse(parts.join('\n'));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [streamingState]);
|
||||
|
||||
cancelHandlerRef.current = useCallback(
|
||||
(shouldRestorePrompt: boolean = true) => {
|
||||
const pendingHistoryItems = [
|
||||
@@ -1430,7 +1493,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
!isThemeDialogOpen &&
|
||||
!isEditorDialogOpen &&
|
||||
!showPrivacyNotice &&
|
||||
geminiClient?.isInitialized?.()
|
||||
geminiClient?.isInitialized?.() &&
|
||||
isMcpReady
|
||||
) {
|
||||
void handleFinalSubmit(initialPrompt);
|
||||
initialPromptSubmitted.current = true;
|
||||
@@ -1445,6 +1509,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isEditorDialogOpen,
|
||||
showPrivacyNotice,
|
||||
geminiClient,
|
||||
isMcpReady,
|
||||
]);
|
||||
|
||||
const [idePromptAnswered, setIdePromptAnswered] = useState(false);
|
||||
@@ -1967,7 +2032,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const nightly = props.version.includes('nightly');
|
||||
|
||||
const dialogsVisible =
|
||||
shouldShowIdePrompt ||
|
||||
shouldShowIdePrompt ||
|
||||
isFolderTrustDialogOpen ||
|
||||
isPolicyUpdateDialogOpen ||
|
||||
@@ -2272,10 +2336,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
...pendingGeminiHistoryItems,
|
||||
]),
|
||||
hintBuffer: '',
|
||||
sisyphusSecondsRemaining,
|
||||
a2aListenerPort,
|
||||
}),
|
||||
[
|
||||
isThemeDialogOpen,
|
||||
|
||||
themeError,
|
||||
isAuthenticating,
|
||||
isConfigInitialized,
|
||||
@@ -2393,6 +2458,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
sisyphusSecondsRemaining,
|
||||
a2aListenerPort,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -105,40 +105,34 @@ describe('agentsCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should reload the agent registry when reload subcommand is called', async () => {
|
||||
it('should reload the agent registry when refresh subcommand is called', async () => {
|
||||
const reloadSpy = vi.fn().mockResolvedValue(undefined);
|
||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
||||
reload: reloadSpy,
|
||||
});
|
||||
|
||||
const reloadCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'reload',
|
||||
const refreshCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'refresh',
|
||||
);
|
||||
expect(reloadCommand).toBeDefined();
|
||||
expect(refreshCommand).toBeDefined();
|
||||
|
||||
const result = await reloadCommand!.action!(mockContext, '');
|
||||
const result = await refreshCommand!.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 reloaded successfully',
|
||||
content: 'Agents refreshed successfully.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show an error if agent registry is not available during reload', async () => {
|
||||
it('should show an error if agent registry is not available during refresh', async () => {
|
||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue(undefined);
|
||||
|
||||
const reloadCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'reload',
|
||||
const refreshCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'refresh',
|
||||
);
|
||||
const result = await reloadCommand!.action!(mockContext, '');
|
||||
const result = await refreshCommand!.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
|
||||
@@ -322,9 +322,9 @@ const configCommand: SlashCommand = {
|
||||
completion: completeAllAgents,
|
||||
};
|
||||
|
||||
const agentsReloadCommand: SlashCommand = {
|
||||
name: 'reload',
|
||||
altNames: ['refresh'],
|
||||
const agentsRefreshCommand: SlashCommand = {
|
||||
name: 'refresh',
|
||||
altNames: ['reload'],
|
||||
description: 'Reload the agent registry',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext) => {
|
||||
@@ -340,7 +340,7 @@ const agentsReloadCommand: SlashCommand = {
|
||||
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: 'Reloading agent registry...',
|
||||
text: 'Refreshing agent registry...',
|
||||
});
|
||||
|
||||
await agentRegistry.reload();
|
||||
@@ -348,7 +348,7 @@ const agentsReloadCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Agents reloaded successfully',
|
||||
content: 'Agents refreshed successfully.',
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -359,7 +359,7 @@ export const agentsCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [
|
||||
agentsListCommand,
|
||||
agentsReloadCommand,
|
||||
agentsRefreshCommand,
|
||||
enableCommand,
|
||||
disableCommand,
|
||||
configCommand,
|
||||
|
||||
@@ -53,6 +53,7 @@ export const compressCommand: SlashCommand = {
|
||||
originalTokenCount: compressed.originalTokenCount,
|
||||
newTokenCount: compressed.newTokenCount,
|
||||
compressionStatus: compressed.compressionStatus,
|
||||
archivePath: compressed.archivePath,
|
||||
},
|
||||
} as HistoryItemCompression,
|
||||
Date.now(),
|
||||
|
||||
@@ -892,7 +892,7 @@ describe('extensionsCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('reload', () => {
|
||||
describe('restart', () => {
|
||||
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 === 'reload',
|
||||
(c) => c.name === 'restart',
|
||||
)?.action;
|
||||
expect(restartAction).not.toBeNull();
|
||||
|
||||
@@ -911,7 +911,7 @@ describe('extensionsCommand', () => {
|
||||
getExtensions: mockGetExtensions,
|
||||
restartExtension: mockRestartExtension,
|
||||
}));
|
||||
mockContext.invocation!.name = 'reload';
|
||||
mockContext.invocation!.name = 'restart';
|
||||
});
|
||||
|
||||
it('should show a message if no extensions are installed', async () => {
|
||||
@@ -930,7 +930,7 @@ describe('extensionsCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reloads all active extensions when --all is provided', async () => {
|
||||
it('restarts 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: 'Reloading 2 extensions...',
|
||||
text: 'Restarting 2 extensions...',
|
||||
}),
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: '2 extensions reloaded successfully',
|
||||
text: '2 extensions restarted successfully.',
|
||||
}),
|
||||
);
|
||||
expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({
|
||||
@@ -986,7 +986,7 @@ describe('extensionsCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reloads only specified active extensions', async () => {
|
||||
it('restarts 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 reload <extension-names>|--all',
|
||||
text: 'Usage: /extensions restart <extension-names>|--all',
|
||||
}),
|
||||
);
|
||||
expect(mockRestartExtension).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles errors during extension reload', async () => {
|
||||
it('handles errors during extension restart', 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 reload some extensions:\n ext1: Failed to restart',
|
||||
text: 'Failed to restart some extensions:\n ext1: Failed to restart',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1066,7 +1066,7 @@ describe('extensionsCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not reload any extensions if none are found', async () => {
|
||||
it('does not restart 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 reload command', async () => {
|
||||
mockContext.invocation!.name = 'reload';
|
||||
it('should suggest only enabled extension names for the restart command', async () => {
|
||||
mockContext.invocation!.name = 'restart';
|
||||
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 reload <extension-names>|--all',
|
||||
text: 'Usage: /extensions restart <extension-names>|--all',
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -208,12 +208,12 @@ async function restartAction(
|
||||
|
||||
const s = extensionsToRestart.length > 1 ? 's' : '';
|
||||
|
||||
const reloadingMessage = {
|
||||
const restartingMessage = {
|
||||
type: MessageType.INFO,
|
||||
text: `Reloading ${extensionsToRestart.length} extension${s}...`,
|
||||
text: `Restarting ${extensionsToRestart.length} extension${s}...`,
|
||||
color: theme.text.primary,
|
||||
};
|
||||
context.ui.addItem(reloadingMessage);
|
||||
context.ui.addItem(restartingMessage);
|
||||
|
||||
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 reload some extensions:\n ${errorMessages}`,
|
||||
text: `Failed to restart some extensions:\n ${errorMessages}`,
|
||||
});
|
||||
} else {
|
||||
const infoItem: HistoryItemInfo = {
|
||||
type: MessageType.INFO,
|
||||
text: `${extensionsToRestart.length} extension${s} reloaded successfully`,
|
||||
text: `${extensionsToRestart.length} extension${s} restarted successfully.`,
|
||||
icon: emptyIcon,
|
||||
color: theme.text.primary,
|
||||
};
|
||||
@@ -729,8 +729,7 @@ export function completeExtensions(
|
||||
}
|
||||
if (
|
||||
context.invocation?.name === 'disable' ||
|
||||
context.invocation?.name === 'restart' ||
|
||||
context.invocation?.name === 'reload'
|
||||
context.invocation?.name === 'restart'
|
||||
) {
|
||||
extensions = extensions.filter((ext) => ext.isActive);
|
||||
}
|
||||
@@ -825,10 +824,9 @@ const exploreExtensionsCommand: SlashCommand = {
|
||||
action: exploreAction,
|
||||
};
|
||||
|
||||
const reloadCommand: SlashCommand = {
|
||||
name: 'reload',
|
||||
altNames: ['restart'],
|
||||
description: 'Reload all extensions',
|
||||
const restartCommand: SlashCommand = {
|
||||
name: 'restart',
|
||||
description: 'Restart all extensions',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
action: restartAction,
|
||||
@@ -865,7 +863,7 @@ export function extensionsCommand(
|
||||
listExtensionsCommand,
|
||||
updateExtensionsCommand,
|
||||
exploreExtensionsCommand,
|
||||
reloadCommand,
|
||||
restartCommand,
|
||||
...conditionalCommands,
|
||||
],
|
||||
action: (context, args) =>
|
||||
|
||||
@@ -149,7 +149,7 @@ const authCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Successfully authenticated and reloaded tools for '${serverName}'`,
|
||||
content: `Successfully authenticated and refreshed tools for '${serverName}'.`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -325,10 +325,10 @@ const schemaCommand: SlashCommand = {
|
||||
action: (context) => listAction(context, true, true),
|
||||
};
|
||||
|
||||
const reloadCommand: SlashCommand = {
|
||||
name: 'reload',
|
||||
altNames: ['refresh'],
|
||||
description: 'Reloads MCP servers',
|
||||
const refreshCommand: SlashCommand = {
|
||||
name: 'refresh',
|
||||
altNames: ['reload'],
|
||||
description: 'Restarts MCP servers',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (
|
||||
@@ -354,7 +354,7 @@ const reloadCommand: SlashCommand = {
|
||||
|
||||
context.ui.addItem({
|
||||
type: 'info',
|
||||
text: 'Reloading MCP servers...',
|
||||
text: 'Restarting MCP servers...',
|
||||
});
|
||||
|
||||
await mcpClientManager.restart();
|
||||
@@ -460,7 +460,7 @@ async function handleEnableDisable(
|
||||
const mcpClientManager = config.getMcpClientManager();
|
||||
if (mcpClientManager) {
|
||||
context.ui.addItem(
|
||||
{ type: 'info', text: 'Reloading MCP servers...' },
|
||||
{ type: 'info', text: 'Restarting MCP servers...' },
|
||||
Date.now(),
|
||||
);
|
||||
await mcpClientManager.restart();
|
||||
@@ -521,7 +521,7 @@ export const mcpCommand: SlashCommand = {
|
||||
descCommand,
|
||||
schemaCommand,
|
||||
authCommand,
|
||||
reloadCommand,
|
||||
refreshCommand,
|
||||
enableCommand,
|
||||
disableCommand,
|
||||
],
|
||||
|
||||
@@ -39,13 +39,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Memory reloaded successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`,
|
||||
content: `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Memory reloaded successfully.',
|
||||
content: 'Memory refreshed successfully.',
|
||||
};
|
||||
}),
|
||||
showMemory: vi.fn(),
|
||||
@@ -63,7 +63,7 @@ describe('memoryCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
const getSubCommand = (
|
||||
name: 'show' | 'add' | 'reload' | 'list',
|
||||
name: 'show' | 'add' | 'refresh' | 'list',
|
||||
): SlashCommand => {
|
||||
const subCommand = memoryCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === name,
|
||||
@@ -206,15 +206,15 @@ describe('memoryCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('/memory reload', () => {
|
||||
let reloadCommand: SlashCommand;
|
||||
describe('/memory refresh', () => {
|
||||
let refreshCommand: SlashCommand;
|
||||
let mockSetUserMemory: Mock;
|
||||
let mockSetGeminiMdFileCount: Mock;
|
||||
let mockSetGeminiMdFilePaths: Mock;
|
||||
let mockContextManagerRefresh: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
reloadCommand = getSubCommand('reload');
|
||||
refreshCommand = getSubCommand('refresh');
|
||||
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 (!reloadCommand.action) throw new Error('Command has no action');
|
||||
if (!refreshCommand.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 reloadCommand.action(mockContext, '');
|
||||
await refreshCommand.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 reloaded successfully. Loaded 18 characters from 3 file(s).',
|
||||
text: 'Memory refreshed successfully. Loaded 18 characters from 3 file(s).',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display success message when memory is reloaded with content (Legacy)', async () => {
|
||||
if (!reloadCommand.action) throw new Error('Command has no action');
|
||||
it('should display success message when memory is refreshed with content (Legacy)', async () => {
|
||||
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const successMessage = {
|
||||
type: 'message',
|
||||
messageType: MessageType.INFO,
|
||||
content:
|
||||
'Memory reloaded successfully. Loaded 18 characters from 2 file(s).',
|
||||
'Memory refreshed successfully. Loaded 18 characters from 2 file(s).',
|
||||
};
|
||||
mockRefreshMemory.mockResolvedValue(successMessage);
|
||||
|
||||
await reloadCommand.action(mockContext, '');
|
||||
await refreshCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Reloading memory from source files...',
|
||||
text: 'Refreshing memory from source files...',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
@@ -316,42 +316,42 @@ describe('memoryCommand', () => {
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Memory reloaded successfully. Loaded 18 characters from 2 file(s).',
|
||||
text: 'Memory refreshed successfully. Loaded 18 characters from 2 file(s).',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display success message when memory is reloaded with no content', async () => {
|
||||
if (!reloadCommand.action) throw new Error('Command has no action');
|
||||
it('should display success message when memory is refreshed with no content', async () => {
|
||||
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const successMessage = {
|
||||
type: 'message',
|
||||
messageType: MessageType.INFO,
|
||||
content: 'Memory reloaded successfully. No memory content found.',
|
||||
content: 'Memory refreshed successfully. No memory content found.',
|
||||
};
|
||||
mockRefreshMemory.mockResolvedValue(successMessage);
|
||||
|
||||
await reloadCommand.action(mockContext, '');
|
||||
await refreshCommand.action(mockContext, '');
|
||||
|
||||
expect(mockRefreshMemory).toHaveBeenCalledOnce();
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Memory reloaded successfully. No memory content found.',
|
||||
text: 'Memory refreshed successfully. No memory content found.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display an error message if reloading fails', async () => {
|
||||
if (!reloadCommand.action) throw new Error('Command has no action');
|
||||
it('should display an error message if refreshing fails', async () => {
|
||||
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const error = new Error('Failed to read memory files.');
|
||||
mockRefreshMemory.mockRejectedValue(error);
|
||||
|
||||
await reloadCommand.action(mockContext, '');
|
||||
await refreshCommand.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 reloading memory: ${error.message}`,
|
||||
text: `Error refreshing memory: ${error.message}`,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw if config service is unavailable', async () => {
|
||||
if (!reloadCommand.action) throw new Error('Command has no action');
|
||||
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const nullConfigContext = createMockCommandContext({
|
||||
services: { config: null },
|
||||
});
|
||||
|
||||
await expect(
|
||||
reloadCommand.action(nullConfigContext, ''),
|
||||
refreshCommand.action(nullConfigContext, ''),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(nullConfigContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Reloading memory from source files...',
|
||||
text: 'Refreshing memory from source files...',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
@@ -63,16 +63,16 @@ export const memoryCommand: SlashCommand = {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'reload',
|
||||
altNames: ['refresh'],
|
||||
description: 'Reload the memory from the source',
|
||||
name: 'refresh',
|
||||
altNames: ['reload'],
|
||||
description: 'Refresh the memory from the source',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Reloading memory from source files...',
|
||||
text: 'Refreshing 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 reloading memory: ${(error as Error).message}`,
|
||||
text: `Error refreshing memory: ${(error as Error).message}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
@@ -20,7 +20,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
UserAccountManager: vi.fn().mockImplementation(() => ({
|
||||
getCachedGoogleAccount: vi.fn().mockReturnValue('mock@example.com'),
|
||||
})),
|
||||
getG1CreditBalance: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -208,6 +208,8 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
},
|
||||
sisyphusSecondsRemaining: null,
|
||||
a2aListenerPort: null,
|
||||
...overrides,
|
||||
}) as UIState;
|
||||
|
||||
|
||||
@@ -311,5 +311,9 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
|
||||
</Box>
|
||||
);
|
||||
|
||||
return <OverflowProvider>{content}</OverflowProvider>;
|
||||
return isAlternateBuffer ? (
|
||||
<OverflowProvider>{content}</OverflowProvider>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
};
|
||||
|
||||
@@ -132,7 +132,9 @@ describe('<Footer />', () => {
|
||||
const output = lastFrame();
|
||||
expect(output).toBeDefined();
|
||||
// Should contain some part of the path, likely shortened
|
||||
expect(output).toContain(path.join('make', 'it'));
|
||||
expect(output).toContain(
|
||||
path.join('directories', 'to', 'make', 'it', 'long'),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -147,38 +149,9 @@ describe('<Footer />', () => {
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toBeDefined();
|
||||
expect(output).toContain(path.join('make', 'it'));
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not truncate high-priority items on narrow terminals (regression)', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
width: 60,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
},
|
||||
settings: createMockSettings({
|
||||
general: {
|
||||
vimMode: true,
|
||||
},
|
||||
ui: {
|
||||
footer: {
|
||||
showLabels: true,
|
||||
items: ['workspace', 'model-name'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
expect(output).toContain(
|
||||
path.join('directories', 'to', 'make', 'it', 'long'),
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
// [INSERT] is high priority and should be fully visible
|
||||
// (Note: VimModeProvider defaults to 'INSERT' mode when enabled)
|
||||
expect(output).toContain('[INSERT]');
|
||||
// Other items should be present but might be shortened
|
||||
expect(output).toContain('gemini-pro');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -262,7 +235,7 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('85%');
|
||||
expect(lastFrame()).toContain('15%');
|
||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -103,9 +103,6 @@ export interface FooterRowItem {
|
||||
key: string;
|
||||
header: string;
|
||||
element: React.ReactNode;
|
||||
flexGrow?: number;
|
||||
flexShrink?: number;
|
||||
isFocused?: boolean;
|
||||
}
|
||||
|
||||
const COLUMN_GAP = 3;
|
||||
@@ -126,20 +123,10 @@ export const FooterRow: React.FC<{
|
||||
}
|
||||
|
||||
elements.push(
|
||||
<Box
|
||||
key={item.key}
|
||||
flexDirection="column"
|
||||
flexGrow={item.flexGrow ?? 0}
|
||||
flexShrink={item.flexShrink ?? 1}
|
||||
backgroundColor={item.isFocused ? theme.background.focus : undefined}
|
||||
>
|
||||
<Box key={item.key} flexDirection="column">
|
||||
{showLabels && (
|
||||
<Box height={1}>
|
||||
<Text
|
||||
color={item.isFocused ? theme.text.primary : theme.ui.comment}
|
||||
>
|
||||
{item.header}
|
||||
</Text>
|
||||
<Text color={theme.ui.comment}>{item.header}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box height={1}>{item.element}</Box>
|
||||
@@ -151,7 +138,6 @@ export const FooterRow: React.FC<{
|
||||
<Box
|
||||
flexDirection="row"
|
||||
flexWrap="nowrap"
|
||||
width="100%"
|
||||
columnGap={showLabels ? COLUMN_GAP : 0}
|
||||
>
|
||||
{elements}
|
||||
@@ -422,50 +408,41 @@ export const Footer: React.FC = () => {
|
||||
}
|
||||
|
||||
// --- Width Fitting Logic ---
|
||||
let currentWidth = 2; // Initial padding
|
||||
const columnsToRender: FooterColumn[] = [];
|
||||
let droppedAny = false;
|
||||
let currentUsedWidth = 2; // Initial padding
|
||||
|
||||
for (const col of potentialColumns) {
|
||||
const gap = columnsToRender.length > 0 ? (showLabels ? COLUMN_GAP : 3) : 0;
|
||||
for (let i = 0; i < potentialColumns.length; i++) {
|
||||
const col = potentialColumns[i];
|
||||
const gap = columnsToRender.length > 0 ? (showLabels ? COLUMN_GAP : 3) : 0; // Use 3 for dot separator width
|
||||
const budgetWidth = col.id === 'workspace' ? 20 : col.width;
|
||||
|
||||
if (
|
||||
col.isHighPriority ||
|
||||
currentUsedWidth + gap + budgetWidth <= terminalWidth - 2
|
||||
currentWidth + gap + budgetWidth <= terminalWidth - 2
|
||||
) {
|
||||
columnsToRender.push(col);
|
||||
currentUsedWidth += gap + budgetWidth;
|
||||
currentWidth += gap + budgetWidth;
|
||||
} else {
|
||||
droppedAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
const totalBudgeted = columnsToRender.reduce(
|
||||
(sum, c, idx) =>
|
||||
sum +
|
||||
(c.id === 'workspace' ? 20 : c.width) +
|
||||
(idx > 0 ? (showLabels ? COLUMN_GAP : 3) : 0),
|
||||
2,
|
||||
);
|
||||
const excessSpace = Math.max(0, terminalWidth - totalBudgeted);
|
||||
|
||||
const rowItems: FooterRowItem[] = columnsToRender.map((col) => {
|
||||
const isWorkspace = col.id === 'workspace';
|
||||
|
||||
// Calculate exact space available for growth to prevent over-estimation truncation
|
||||
const otherItemsWidth = columnsToRender
|
||||
.filter((c) => c.id !== 'workspace')
|
||||
.reduce((sum, c) => sum + c.width, 0);
|
||||
const numItems = columnsToRender.length + (droppedAny ? 1 : 0);
|
||||
const numGaps = numItems > 1 ? numItems - 1 : 0;
|
||||
const gapsWidth = numGaps * (showLabels ? COLUMN_GAP : 3);
|
||||
const ellipsisWidth = droppedAny ? 1 : 0;
|
||||
|
||||
const availableForWorkspace = Math.max(
|
||||
20,
|
||||
terminalWidth - 2 - gapsWidth - otherItemsWidth - ellipsisWidth,
|
||||
);
|
||||
|
||||
const estimatedWidth = isWorkspace ? availableForWorkspace : col.width;
|
||||
|
||||
const maxWidth = col.id === 'workspace' ? 20 + excessSpace : col.width;
|
||||
return {
|
||||
key: col.id,
|
||||
header: col.header,
|
||||
element: col.element(estimatedWidth),
|
||||
flexGrow: isWorkspace ? 1 : 0,
|
||||
flexShrink: isWorkspace ? 1 : 0,
|
||||
element: col.element(maxWidth),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -474,8 +451,6 @@ export const Footer: React.FC = () => {
|
||||
key: 'ellipsis',
|
||||
header: '',
|
||||
element: <Text color={theme.ui.comment}>…</Text>,
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -24,14 +24,13 @@ describe('<FooterConfigDialog />', () => {
|
||||
|
||||
it('renders correctly with default settings', async () => {
|
||||
const settings = createMockSettings();
|
||||
const renderResult = renderWithProviders(
|
||||
const { lastFrame, waitUntilReady } = renderWithProviders(
|
||||
<FooterConfigDialog onClose={mockOnClose} />,
|
||||
{ settings },
|
||||
);
|
||||
|
||||
await renderResult.waitUntilReady();
|
||||
expect(renderResult.lastFrame()).toMatchSnapshot();
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('toggles an item when enter is pressed', async () => {
|
||||
@@ -67,7 +66,7 @@ describe('<FooterConfigDialog />', () => {
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
// Initial order: workspace, git-branch, ...
|
||||
// Initial order: workspace, branch, ...
|
||||
const output = lastFrame();
|
||||
const cwdIdx = output.indexOf('] workspace');
|
||||
const branchIdx = output.indexOf('] git-branch');
|
||||
@@ -109,40 +108,22 @@ describe('<FooterConfigDialog />', () => {
|
||||
|
||||
it('highlights the active item in the preview', async () => {
|
||||
const settings = createMockSettings();
|
||||
const renderResult = renderWithProviders(
|
||||
const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
|
||||
<FooterConfigDialog onClose={mockOnClose} />,
|
||||
{ settings },
|
||||
);
|
||||
|
||||
const { lastFrame, stdin, waitUntilReady } = renderResult;
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('~/project/path');
|
||||
|
||||
// Move focus down to 'code-changes' (which has colored elements)
|
||||
for (let i = 0; i < 8; i++) {
|
||||
act(() => {
|
||||
stdin.write('\u001b[B'); // Down arrow
|
||||
});
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
// The selected indicator should be next to 'code-changes'
|
||||
expect(lastFrame()).toMatch(/> \[ \] code-changes/);
|
||||
});
|
||||
|
||||
// Toggle it on
|
||||
// Move focus down to 'git-branch'
|
||||
act(() => {
|
||||
stdin.write('\r');
|
||||
stdin.write('\u001b[B'); // Down arrow
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// It should now be checked and appear in the preview
|
||||
expect(lastFrame()).toMatch(/> \[✓\] code-changes/);
|
||||
expect(lastFrame()).toContain('+12 -4');
|
||||
expect(lastFrame()).toContain('main');
|
||||
});
|
||||
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
});
|
||||
|
||||
it('shows an empty preview when all items are deselected', async () => {
|
||||
@@ -153,64 +134,20 @@ describe('<FooterConfigDialog />', () => {
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
// Default items are the first 5. We toggle them off.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
act(() => {
|
||||
stdin.write('\r'); // Toggle off
|
||||
});
|
||||
for (let i = 0; i < 10; i++) {
|
||||
act(() => {
|
||||
stdin.write('\r'); // Toggle (deselect)
|
||||
stdin.write('\u001b[B'); // Down arrow
|
||||
});
|
||||
}
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Preview:');
|
||||
expect(output).not.toContain('~/project/path');
|
||||
expect(output).not.toContain('docker');
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('moves item correctly after trying to move up at the top', async () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
|
||||
<FooterConfigDialog onClose={mockOnClose} />,
|
||||
{ settings },
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Default initial items in mock settings are 'git-branch', 'workspace', ...
|
||||
await waitFor(() => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('] git-branch');
|
||||
expect(output).toContain('] workspace');
|
||||
});
|
||||
|
||||
const output = lastFrame();
|
||||
const branchIdx = output.indexOf('] git-branch');
|
||||
const workspaceIdx = output.indexOf('] workspace');
|
||||
expect(workspaceIdx).toBeLessThan(branchIdx);
|
||||
|
||||
// Try to move workspace up (left arrow) while it's at the top
|
||||
act(() => {
|
||||
stdin.write('\u001b[D'); // Left arrow
|
||||
});
|
||||
|
||||
// Move workspace down (right arrow)
|
||||
act(() => {
|
||||
stdin.write('\u001b[C'); // Right arrow
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const outputAfter = lastFrame();
|
||||
const bIdxAfter = outputAfter.indexOf('] git-branch');
|
||||
const wIdxAfter = outputAfter.indexOf('] workspace');
|
||||
// workspace should now be after git-branch
|
||||
expect(bIdxAfter).toBeLessThan(wIdxAfter);
|
||||
expect(output).toContain('Preview:');
|
||||
expect(output).not.toContain('~/project/path');
|
||||
expect(output).not.toContain('docker');
|
||||
expect(output).not.toContain('gemini-2.5-pro');
|
||||
expect(output).not.toContain('1.2k left');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,75 +5,119 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useMemo, useReducer, useState } from 'react';
|
||||
import { useCallback, useMemo, useReducer } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useSettingsStore } from '../contexts/SettingsContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useKeypress, type Key } from '../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
import { FooterRow, type FooterRowItem } from './Footer.js';
|
||||
import { ALL_ITEMS, resolveFooterState } from '../../config/footerItems.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { BaseSelectionList } from './shared/BaseSelectionList.js';
|
||||
import type { SelectionListItem } from '../hooks/useSelectionList.js';
|
||||
import { DialogFooter } from './shared/DialogFooter.js';
|
||||
|
||||
interface FooterConfigDialogProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
interface FooterConfigItem {
|
||||
key: string;
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
type: 'config' | 'labels-toggle' | 'reset';
|
||||
}
|
||||
|
||||
interface FooterConfigState {
|
||||
orderedIds: string[];
|
||||
selectedIds: Set<string>;
|
||||
activeIndex: number;
|
||||
scrollOffset: number;
|
||||
}
|
||||
|
||||
type FooterConfigAction =
|
||||
| { type: 'MOVE_ITEM'; id: string; direction: number }
|
||||
| { type: 'TOGGLE_ITEM'; id: string }
|
||||
| { type: 'SET_STATE'; payload: Partial<FooterConfigState> };
|
||||
| { type: 'MOVE_UP'; itemCount: number; maxToShow: number }
|
||||
| { type: 'MOVE_DOWN'; itemCount: number; maxToShow: number }
|
||||
| {
|
||||
type: 'MOVE_LEFT';
|
||||
items: Array<{ key: string }>;
|
||||
}
|
||||
| {
|
||||
type: 'MOVE_RIGHT';
|
||||
items: Array<{ key: string }>;
|
||||
}
|
||||
| { type: 'TOGGLE_ITEM'; items: Array<{ key: string }> }
|
||||
| { type: 'SET_STATE'; payload: Partial<FooterConfigState> }
|
||||
| { type: 'RESET_INDEX' };
|
||||
|
||||
function footerConfigReducer(
|
||||
state: FooterConfigState,
|
||||
action: FooterConfigAction,
|
||||
): FooterConfigState {
|
||||
switch (action.type) {
|
||||
case 'MOVE_ITEM': {
|
||||
const currentIndex = state.orderedIds.indexOf(action.id);
|
||||
const newIndex = currentIndex + action.direction;
|
||||
if (
|
||||
currentIndex === -1 ||
|
||||
newIndex < 0 ||
|
||||
newIndex >= state.orderedIds.length
|
||||
) {
|
||||
return state;
|
||||
case 'MOVE_UP': {
|
||||
const { itemCount, maxToShow } = action;
|
||||
const totalSlots = itemCount + 2; // +1 for showLabels, +1 for reset
|
||||
const newIndex =
|
||||
state.activeIndex > 0 ? state.activeIndex - 1 : totalSlots - 1;
|
||||
let newOffset = state.scrollOffset;
|
||||
|
||||
if (newIndex < itemCount) {
|
||||
if (newIndex === itemCount - 1) {
|
||||
newOffset = Math.max(0, itemCount - maxToShow);
|
||||
} else if (newIndex < state.scrollOffset) {
|
||||
newOffset = newIndex;
|
||||
}
|
||||
}
|
||||
return { ...state, activeIndex: newIndex, scrollOffset: newOffset };
|
||||
}
|
||||
case 'MOVE_DOWN': {
|
||||
const { itemCount, maxToShow } = action;
|
||||
const totalSlots = itemCount + 2;
|
||||
const newIndex =
|
||||
state.activeIndex < totalSlots - 1 ? state.activeIndex + 1 : 0;
|
||||
let newOffset = state.scrollOffset;
|
||||
|
||||
if (newIndex === 0) {
|
||||
newOffset = 0;
|
||||
} else if (
|
||||
newIndex < itemCount &&
|
||||
newIndex >= state.scrollOffset + maxToShow
|
||||
) {
|
||||
newOffset = newIndex - maxToShow + 1;
|
||||
}
|
||||
return { ...state, activeIndex: newIndex, scrollOffset: newOffset };
|
||||
}
|
||||
case 'MOVE_LEFT':
|
||||
case 'MOVE_RIGHT': {
|
||||
const direction = action.type === 'MOVE_LEFT' ? -1 : 1;
|
||||
const currentItem = action.items[state.activeIndex];
|
||||
if (!currentItem) return state;
|
||||
|
||||
const currentId = currentItem.key;
|
||||
const currentIndex = state.orderedIds.indexOf(currentId);
|
||||
const newIndex = currentIndex + direction;
|
||||
|
||||
if (newIndex < 0 || newIndex >= state.orderedIds.length) return state;
|
||||
|
||||
const newOrderedIds = [...state.orderedIds];
|
||||
[newOrderedIds[currentIndex], newOrderedIds[newIndex]] = [
|
||||
newOrderedIds[newIndex],
|
||||
newOrderedIds[currentIndex],
|
||||
];
|
||||
return { ...state, orderedIds: newOrderedIds };
|
||||
|
||||
return { ...state, orderedIds: newOrderedIds, activeIndex: newIndex };
|
||||
}
|
||||
case 'TOGGLE_ITEM': {
|
||||
const isSystemFocused = state.activeIndex >= action.items.length;
|
||||
if (isSystemFocused) return state;
|
||||
|
||||
const item = action.items[state.activeIndex];
|
||||
if (!item) return state;
|
||||
|
||||
const nextSelected = new Set(state.selectedIds);
|
||||
if (nextSelected.has(action.id)) {
|
||||
nextSelected.delete(action.id);
|
||||
if (nextSelected.has(item.key)) {
|
||||
nextSelected.delete(item.key);
|
||||
} else {
|
||||
nextSelected.add(action.id);
|
||||
nextSelected.add(item.key);
|
||||
}
|
||||
return { ...state, selectedIds: nextSelected };
|
||||
}
|
||||
case 'SET_STATE':
|
||||
return { ...state, ...action.payload };
|
||||
case 'RESET_INDEX':
|
||||
return { ...state, activeIndex: 0, scrollOffset: 0 };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -83,54 +127,40 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const { settings, setSetting } = useSettingsStore();
|
||||
const { constrainHeight, terminalHeight, staticExtraHeight } = useUIState();
|
||||
const [state, dispatch] = useReducer(footerConfigReducer, undefined, () =>
|
||||
resolveFooterState(settings.merged),
|
||||
);
|
||||
const maxItemsToShow = 10;
|
||||
|
||||
const { orderedIds, selectedIds } = state;
|
||||
const [focusKey, setFocusKey] = useState<string | undefined>(orderedIds[0]);
|
||||
const [state, dispatch] = useReducer(footerConfigReducer, undefined, () => ({
|
||||
...resolveFooterState(settings.merged),
|
||||
activeIndex: 0,
|
||||
scrollOffset: 0,
|
||||
}));
|
||||
|
||||
const listItems = useMemo((): Array<SelectionListItem<FooterConfigItem>> => {
|
||||
const items: Array<SelectionListItem<FooterConfigItem>> = orderedIds
|
||||
.map((id: string) => {
|
||||
const item = ALL_ITEMS.find((i) => i.id === id);
|
||||
if (!item) return null;
|
||||
return {
|
||||
key: id,
|
||||
value: {
|
||||
const { orderedIds, selectedIds, activeIndex, scrollOffset } = state;
|
||||
|
||||
// Prepare items
|
||||
const listItems = useMemo(
|
||||
() =>
|
||||
orderedIds
|
||||
.map((id: string) => {
|
||||
const item = ALL_ITEMS.find((i) => i.id === id);
|
||||
if (!item) return null;
|
||||
return {
|
||||
key: id,
|
||||
id,
|
||||
label: item.id,
|
||||
description: item.description as string,
|
||||
type: 'config' as const,
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter((i): i is NonNullable<typeof i> => i !== null);
|
||||
};
|
||||
})
|
||||
.filter((i): i is NonNullable<typeof i> => i !== null),
|
||||
[orderedIds],
|
||||
);
|
||||
|
||||
items.push({
|
||||
key: 'show-labels',
|
||||
value: {
|
||||
key: 'show-labels',
|
||||
id: 'show-labels',
|
||||
label: 'Show footer labels',
|
||||
type: 'labels-toggle',
|
||||
},
|
||||
});
|
||||
const maxLabelWidth = useMemo(
|
||||
() => listItems.reduce((max, item) => Math.max(max, item.label.length), 0),
|
||||
[listItems],
|
||||
);
|
||||
|
||||
items.push({
|
||||
key: 'reset',
|
||||
value: {
|
||||
key: 'reset',
|
||||
id: 'reset',
|
||||
label: 'Reset to default footer',
|
||||
type: 'reset',
|
||||
},
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [orderedIds]);
|
||||
const isResetFocused = activeIndex === listItems.length + 1;
|
||||
const isShowLabelsFocused = activeIndex === listItems.length;
|
||||
|
||||
const handleSaveAndClose = useCallback(() => {
|
||||
const finalItems = orderedIds.filter((id: string) => selectedIds.has(id));
|
||||
@@ -149,9 +179,14 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
|
||||
|
||||
const handleResetToDefaults = useCallback(() => {
|
||||
setSetting(SettingScope.User, 'ui.footer.items', undefined);
|
||||
const newState = resolveFooterState(settings.merged);
|
||||
dispatch({ type: 'SET_STATE', payload: newState });
|
||||
setFocusKey(newState.orderedIds[0]);
|
||||
dispatch({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
...resolveFooterState(settings.merged),
|
||||
activeIndex: 0,
|
||||
scrollOffset: 0,
|
||||
},
|
||||
});
|
||||
}, [setSetting, settings.merged]);
|
||||
|
||||
const handleToggleLabels = useCallback(() => {
|
||||
@@ -159,23 +194,6 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
|
||||
setSetting(SettingScope.User, 'ui.footer.showLabels', !current);
|
||||
}, [setSetting, settings.merged.ui.footer.showLabels]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(item: FooterConfigItem) => {
|
||||
if (item.type === 'config') {
|
||||
dispatch({ type: 'TOGGLE_ITEM', id: item.id });
|
||||
} else if (item.type === 'labels-toggle') {
|
||||
handleToggleLabels();
|
||||
} else if (item.type === 'reset') {
|
||||
handleResetToDefaults();
|
||||
}
|
||||
},
|
||||
[handleResetToDefaults, handleToggleLabels],
|
||||
);
|
||||
|
||||
const handleHighlight = useCallback((item: FooterConfigItem) => {
|
||||
setFocusKey(item.key);
|
||||
}, []);
|
||||
|
||||
useKeypress(
|
||||
(key: Key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
@@ -183,18 +201,43 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
|
||||
dispatch({
|
||||
type: 'MOVE_UP',
|
||||
itemCount: listItems.length,
|
||||
maxToShow: maxItemsToShow,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
|
||||
dispatch({
|
||||
type: 'MOVE_DOWN',
|
||||
itemCount: listItems.length,
|
||||
maxToShow: maxItemsToShow,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.MOVE_LEFT](key)) {
|
||||
if (focusKey && orderedIds.includes(focusKey)) {
|
||||
dispatch({ type: 'MOVE_ITEM', id: focusKey, direction: -1 });
|
||||
return true;
|
||||
}
|
||||
dispatch({ type: 'MOVE_LEFT', items: listItems });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.MOVE_RIGHT](key)) {
|
||||
if (focusKey && orderedIds.includes(focusKey)) {
|
||||
dispatch({ type: 'MOVE_ITEM', id: focusKey, direction: 1 });
|
||||
return true;
|
||||
dispatch({ type: 'MOVE_RIGHT', items: listItems });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.RETURN](key) || key.name === 'space') {
|
||||
if (isResetFocused) {
|
||||
handleResetToDefaults();
|
||||
} else if (isShowLabelsFocused) {
|
||||
handleToggleLabels();
|
||||
} else {
|
||||
dispatch({ type: 'TOGGLE_ITEM', items: listItems });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -202,11 +245,17 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
|
||||
{ isActive: true, priority: true },
|
||||
);
|
||||
|
||||
const visibleItems = listItems.slice(
|
||||
scrollOffset,
|
||||
scrollOffset + maxItemsToShow,
|
||||
);
|
||||
|
||||
const activeId = listItems[activeIndex]?.key;
|
||||
const showLabels = settings.merged.ui.footer.showLabels !== false;
|
||||
|
||||
// Preview logic
|
||||
const previewContent = useMemo(() => {
|
||||
if (focusKey === 'reset') {
|
||||
if (isResetFocused) {
|
||||
return (
|
||||
<Text color={theme.ui.comment} italic>
|
||||
Default footer (uses legacy settings)
|
||||
@@ -220,9 +269,8 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
|
||||
if (itemsToPreview.length === 0) return null;
|
||||
|
||||
const itemColor = showLabels ? theme.text.primary : theme.ui.comment;
|
||||
|
||||
const getColor = (id: string, defaultColor?: string) =>
|
||||
defaultColor || itemColor;
|
||||
id === activeId ? 'white' : defaultColor || itemColor;
|
||||
|
||||
// Mock data for preview (headers come from ALL_ITEMS)
|
||||
const mockData: Record<string, React.ReactNode> = {
|
||||
@@ -264,43 +312,16 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
|
||||
key: id,
|
||||
header: ALL_ITEMS.find((i) => i.id === id)?.header ?? id,
|
||||
element: mockData[id],
|
||||
flexGrow: 1,
|
||||
isFocused: id === focusKey,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box overflow="hidden" flexWrap="nowrap" width="100%">
|
||||
<FooterRow items={rowItems} showLabels={showLabels} />
|
||||
<Box overflow="hidden" flexWrap="nowrap">
|
||||
<Box flexShrink={0}>
|
||||
<FooterRow items={rowItems} showLabels={showLabels} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}, [orderedIds, selectedIds, focusKey, showLabels]);
|
||||
|
||||
const availableTerminalHeight = constrainHeight
|
||||
? terminalHeight - staticExtraHeight
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
|
||||
const BORDER_HEIGHT = 2; // Outer round border
|
||||
const STATIC_ELEMENTS = 13; // Text, margins, preview box, dialog footer
|
||||
|
||||
// Default padding adds 2 lines (top and bottom)
|
||||
let includePadding = true;
|
||||
if (availableTerminalHeight < BORDER_HEIGHT + 2 + STATIC_ELEMENTS + 6) {
|
||||
includePadding = false;
|
||||
}
|
||||
|
||||
const effectivePaddingY = includePadding ? 2 : 0;
|
||||
const availableListSpace = Math.max(
|
||||
0,
|
||||
availableTerminalHeight -
|
||||
BORDER_HEIGHT -
|
||||
effectivePaddingY -
|
||||
STATIC_ELEMENTS,
|
||||
);
|
||||
|
||||
const maxItemsToShow = Math.max(
|
||||
1,
|
||||
Math.min(listItems.length, Math.floor(availableListSpace / 2)),
|
||||
);
|
||||
}, [orderedIds, selectedIds, activeId, isResetFocused, showLabels]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -308,7 +329,7 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={2}
|
||||
paddingY={includePadding ? 1 : 0}
|
||||
paddingY={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold>Configure Footer{'\n'}</Text>
|
||||
@@ -316,65 +337,59 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
|
||||
Select which items to display in the footer.
|
||||
</Text>
|
||||
|
||||
<Box flexDirection="column" marginTop={1} flexGrow={1}>
|
||||
<BaseSelectionList<FooterConfigItem>
|
||||
items={listItems}
|
||||
onSelect={handleSelect}
|
||||
onHighlight={handleHighlight}
|
||||
focusKey={focusKey}
|
||||
showNumbers={false}
|
||||
maxItemsToShow={maxItemsToShow}
|
||||
showScrollArrows={true}
|
||||
selectedIndicator=">"
|
||||
renderItem={(item, { isSelected, titleColor }) => {
|
||||
const configItem = item.value;
|
||||
const isChecked =
|
||||
configItem.type === 'config'
|
||||
? selectedIds.has(configItem.id)
|
||||
: configItem.type === 'labels-toggle'
|
||||
? showLabels
|
||||
: false;
|
||||
<Box flexDirection="column" marginTop={1} minHeight={maxItemsToShow}>
|
||||
{visibleItems.length === 0 ? (
|
||||
<Text color={theme.text.secondary}>No items found.</Text>
|
||||
) : (
|
||||
visibleItems.map((item, idx) => {
|
||||
const index = scrollOffset + idx;
|
||||
const isFocused = index === activeIndex;
|
||||
const isChecked = selectedIds.has(item.key);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Box flexDirection="row">
|
||||
{configItem.type !== 'reset' && (
|
||||
<Text
|
||||
color={
|
||||
isChecked ? theme.status.success : theme.text.secondary
|
||||
}
|
||||
>
|
||||
[{isChecked ? '✓' : ' '}]
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
color={
|
||||
configItem.type === 'reset' && isSelected
|
||||
? theme.status.warning
|
||||
: titleColor
|
||||
}
|
||||
>
|
||||
{configItem.type !== 'reset' ? ' ' : ''}
|
||||
{configItem.label}
|
||||
</Text>
|
||||
</Box>
|
||||
{configItem.description && (
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{' '}
|
||||
{configItem.description}
|
||||
</Text>
|
||||
)}
|
||||
<Box key={item.key} flexDirection="row">
|
||||
<Text color={isFocused ? theme.status.success : undefined}>
|
||||
{isFocused ? '> ' : ' '}
|
||||
</Text>
|
||||
<Text
|
||||
color={isFocused ? theme.status.success : theme.text.primary}
|
||||
>
|
||||
[{isChecked ? '✓' : ' '}]{' '}
|
||||
{item.label.padEnd(maxLabelWidth + 1)}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> {item.description}</Text>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<DialogFooter
|
||||
primaryAction="Enter to select"
|
||||
navigationActions="↑/↓ to navigate · ←/→ to reorder"
|
||||
cancelAction="Esc to close"
|
||||
/>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box flexDirection="row">
|
||||
<Text color={isShowLabelsFocused ? theme.status.success : undefined}>
|
||||
{isShowLabelsFocused ? '> ' : ' '}
|
||||
</Text>
|
||||
<Text color={isShowLabelsFocused ? theme.status.success : undefined}>
|
||||
[{showLabels ? '✓' : ' '}] Show footer labels
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Text color={isResetFocused ? theme.status.warning : undefined}>
|
||||
{isResetFocused ? '> ' : ' '}
|
||||
</Text>
|
||||
<Text
|
||||
color={isResetFocused ? theme.status.warning : theme.text.secondary}
|
||||
>
|
||||
Reset to default footer
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
↑/↓ navigate · ←/→ reorder · enter/space select · esc close
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
marginTop={1}
|
||||
@@ -384,9 +399,7 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
|
||||
flexDirection="column"
|
||||
>
|
||||
<Text bold>Preview:</Text>
|
||||
<Box flexDirection="row" width="100%">
|
||||
{previewContent}
|
||||
</Box>
|
||||
<Box flexDirection="row">{previewContent}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -5,20 +5,10 @@
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect } 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} />,
|
||||
@@ -46,7 +36,7 @@ describe('QuotaDisplay', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not render when usage < 80%', async () => {
|
||||
it('should not render when usage > 20%', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<QuotaDisplay remaining={85} limit={100} />,
|
||||
);
|
||||
@@ -55,7 +45,7 @@ describe('QuotaDisplay', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render warning when used >= 80%', async () => {
|
||||
it('should render yellow when usage < 20%', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<QuotaDisplay remaining={15} limit={100} />,
|
||||
);
|
||||
@@ -64,7 +54,7 @@ describe('QuotaDisplay', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render critical when used >= 95%', async () => {
|
||||
it('should render red when usage < 5%', 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 {
|
||||
getUsedStatusColor,
|
||||
QUOTA_USED_WARNING_THRESHOLD,
|
||||
QUOTA_USED_CRITICAL_THRESHOLD,
|
||||
getStatusColor,
|
||||
QUOTA_THRESHOLD_HIGH,
|
||||
QUOTA_THRESHOLD_MEDIUM,
|
||||
} from '../utils/displayUtils.js';
|
||||
import { formatResetTime } from '../utils/formatters.js';
|
||||
|
||||
@@ -34,36 +34,32 @@ export const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const usedPercentage = 100 - (remaining / limit) * 100;
|
||||
const percentage = (remaining / limit) * 100;
|
||||
|
||||
if (!forceShow && usedPercentage < QUOTA_USED_WARNING_THRESHOLD) {
|
||||
if (!forceShow && percentage > QUOTA_THRESHOLD_HIGH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const color = getUsedStatusColor(usedPercentage, {
|
||||
warning: QUOTA_USED_WARNING_THRESHOLD,
|
||||
critical: QUOTA_USED_CRITICAL_THRESHOLD,
|
||||
const color = getStatusColor(percentage, {
|
||||
green: QUOTA_THRESHOLD_HIGH,
|
||||
yellow: QUOTA_THRESHOLD_MEDIUM,
|
||||
});
|
||||
|
||||
let text: string;
|
||||
const resetInfo =
|
||||
!terse && resetTime ? `, ${formatResetTime(resetTime)}` : '';
|
||||
|
||||
if (remaining === 0) {
|
||||
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
|
||||
? 'Limit reached'
|
||||
: `/stats Limit reached${resetInfo}${!terse && '. /auth to continue.'}`;
|
||||
if (lowercase) text = text.toLowerCase();
|
||||
return <Text color={color}>{text}</Text>;
|
||||
}
|
||||
|
||||
if (lowercase) {
|
||||
text = text.toLowerCase();
|
||||
}
|
||||
let text = terse
|
||||
? `${percentage.toFixed(0)}%`
|
||||
: `/stats ${percentage.toFixed(0)}% usage remaining${resetInfo}`;
|
||||
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 {
|
||||
getUsedStatusColor,
|
||||
QUOTA_USED_WARNING_THRESHOLD,
|
||||
QUOTA_USED_CRITICAL_THRESHOLD,
|
||||
getStatusColor,
|
||||
QUOTA_THRESHOLD_HIGH,
|
||||
QUOTA_THRESHOLD_MEDIUM,
|
||||
} from '../utils/displayUtils.js';
|
||||
|
||||
interface QuotaStatsInfoProps {
|
||||
@@ -31,26 +31,19 @@ export const QuotaStatsInfo: React.FC<QuotaStatsInfoProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const usedPercentage = 100 - (remaining / limit) * 100;
|
||||
const color = getUsedStatusColor(usedPercentage, {
|
||||
warning: QUOTA_USED_WARNING_THRESHOLD,
|
||||
critical: QUOTA_USED_CRITICAL_THRESHOLD,
|
||||
const percentage = (remaining / limit) * 100;
|
||||
const color = getStatusColor(percentage, {
|
||||
green: QUOTA_THRESHOLD_HIGH,
|
||||
yellow: QUOTA_THRESHOLD_MEDIUM,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={0} marginBottom={0}>
|
||||
<Text color={color}>
|
||||
{remaining === 0
|
||||
? `Limit reached${
|
||||
resetTime
|
||||
? `, resets in ${formatResetTime(resetTime, 'terse')}`
|
||||
: ''
|
||||
}`
|
||||
: `${usedPercentage.toFixed(0)}% used${
|
||||
resetTime
|
||||
? ` (Limit resets in ${formatResetTime(resetTime, 'terse')})`
|
||||
: ''
|
||||
}`}
|
||||
? `Limit reached`
|
||||
: `${percentage.toFixed(0)}% usage remaining`}
|
||||
{resetTime && `, ${formatResetTime(resetTime)}`}
|
||||
</Text>
|
||||
{showDetails && (
|
||||
<>
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('ShowMoreLines', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('renders message in STANDARD mode when overflowing', async () => {
|
||||
it('renders nothing in STANDARD mode even if overflowing', async () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(false);
|
||||
mockUseOverflowState.mockReturnValue({
|
||||
overflowingIds: new Set(['1']),
|
||||
@@ -55,9 +55,7 @@ describe('ShowMoreLines', () => {
|
||||
<ShowMoreLines constrainHeight={true} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame().toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
);
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
@@ -19,6 +20,7 @@ export const ShowMoreLines = ({
|
||||
constrainHeight,
|
||||
isOverflowing: isOverflowingProp,
|
||||
}: ShowMoreLinesProps) => {
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const overflowState = useOverflowState();
|
||||
const streamingState = useStreamingContext();
|
||||
|
||||
@@ -27,6 +29,7 @@ export const ShowMoreLines = ({
|
||||
(overflowState !== undefined && overflowState.overflowingIds.size > 0);
|
||||
|
||||
if (
|
||||
!isAlternateBuffer ||
|
||||
!isOverflowing ||
|
||||
!constrainHeight ||
|
||||
!(
|
||||
|
||||
@@ -64,32 +64,4 @@ 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,14 +68,6 @@ 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();
|
||||
|
||||
@@ -473,9 +465,9 @@ describe('<StatsDisplay />', () => {
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Model usage');
|
||||
expect(output).toContain('25%');
|
||||
expect(output).toContain('Usage resets');
|
||||
expect(output).toContain('Usage remaining');
|
||||
expect(output).toContain('75.0%');
|
||||
expect(output).toContain('resets in 1h 30m');
|
||||
expect(output).toMatchSnapshot();
|
||||
|
||||
vi.useRealTimers();
|
||||
@@ -529,8 +521,8 @@ describe('<StatsDisplay />', () => {
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
|
||||
// (1 - 710/1100) * 100 = 35.5%
|
||||
expect(output).toContain('35%');
|
||||
// (10 + 700) / (100 + 1000) = 710 / 1100 = 64.5%
|
||||
expect(output).toContain('65% usage remaining');
|
||||
expect(output).toContain('Usage limit: 1,100');
|
||||
expect(output).toMatchSnapshot();
|
||||
|
||||
@@ -579,8 +571,8 @@ describe('<StatsDisplay />', () => {
|
||||
|
||||
expect(output).toContain('gemini-2.5-flash');
|
||||
expect(output).toContain('-'); // for requests
|
||||
expect(output).toContain('50%');
|
||||
expect(output).toContain('Usage resets');
|
||||
expect(output).toContain('50.0%');
|
||||
expect(output).toContain('resets in 2h');
|
||||
expect(output).toMatchSnapshot();
|
||||
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text, useStdout } from 'ink';
|
||||
import { Box, Text } from 'ink';
|
||||
import { ThemedGradient } from './ThemedGradient.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { formatDuration, formatResetTime } from '../utils/formatters.js';
|
||||
@@ -19,9 +19,6 @@ 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 {
|
||||
@@ -158,8 +155,6 @@ 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) {
|
||||
@@ -168,47 +163,12 @@ const ModelUsageTable: React.FC<{
|
||||
|
||||
const showQuotaColumn = !!quotas && rows.some((row) => !!row.bucket);
|
||||
|
||||
const nameWidth = 23;
|
||||
const requestsWidth = 5;
|
||||
const nameWidth = 25;
|
||||
const requestsWidth = 7;
|
||||
const uncachedWidth = 15;
|
||||
const cachedWidth = 14;
|
||||
const outputTokensWidth = 15;
|
||||
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 usageLimitWidth = showQuotaColumn ? 28 : 0;
|
||||
|
||||
const cacheEfficiencyColor = getStatusColor(cacheEfficiency, {
|
||||
green: CACHE_EFFICIENCY_HIGH,
|
||||
@@ -219,13 +179,25 @@ const ModelUsageTable: React.FC<{
|
||||
nameWidth +
|
||||
requestsWidth +
|
||||
(showQuotaColumn
|
||||
? usageLimitWidth + percentageWidth + resetWidth
|
||||
? usageLimitWidth
|
||||
: 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 &&
|
||||
@@ -244,7 +216,7 @@ const ModelUsageTable: React.FC<{
|
||||
)}
|
||||
|
||||
<Box alignItems="flex-end">
|
||||
<Box width={nameWidth} flexShrink={0}>
|
||||
<Box width={nameWidth}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Model
|
||||
</Text>
|
||||
@@ -295,31 +267,15 @@ const ModelUsageTable: React.FC<{
|
||||
</>
|
||||
)}
|
||||
{showQuotaColumn && (
|
||||
<>
|
||||
<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
|
||||
width={usageLimitWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Usage remaining
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -334,150 +290,84 @@ const ModelUsageTable: React.FC<{
|
||||
width={totalWidth}
|
||||
></Box>
|
||||
|
||||
{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}
|
||||
{rows.map((row) => (
|
||||
<Box key={row.key}>
|
||||
<Box width={nameWidth}>
|
||||
<Text
|
||||
color={row.isActive ? theme.text.primary : theme.text.secondary}
|
||||
wrap="truncate-end"
|
||||
>
|
||||
<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
|
||||
}
|
||||
>
|
||||
{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
|
||||
}
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
{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
|
||||
}
|
||||
>
|
||||
{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
|
||||
}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{cacheEfficiency > 0 && !showQuotaColumn && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
|
||||
@@ -54,6 +54,8 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
|
||||
backgroundShellCount: 0,
|
||||
buffer: { text: '' },
|
||||
history: [{ id: 1, type: 'user', text: 'test' }],
|
||||
sisyphusSecondsRemaining: null,
|
||||
a2aListenerPort: null,
|
||||
...overrides,
|
||||
}) as UIState;
|
||||
|
||||
@@ -171,4 +173,42 @@ describe('StatusDisplay', () => {
|
||||
expect(lastFrame()).toContain('Shells: 3');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders Sisyphus countdown timer when active', async () => {
|
||||
const uiState = createMockUIState({
|
||||
sisyphusSecondsRemaining: 65, // 01:05
|
||||
});
|
||||
const { lastFrame, unmount } = await renderStatusDisplay(
|
||||
{ hideContextSummary: false },
|
||||
uiState,
|
||||
);
|
||||
expect(lastFrame()).toContain('✦ Resuming work in 01:05');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders A2A listener port when active', async () => {
|
||||
const uiState = createMockUIState({
|
||||
a2aListenerPort: 8080,
|
||||
});
|
||||
const { lastFrame, unmount } = await renderStatusDisplay(
|
||||
{ hideContextSummary: false },
|
||||
uiState,
|
||||
);
|
||||
expect(lastFrame()).toContain('⚡ A2A :8080');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders both A2A port and Sisyphus timer together', async () => {
|
||||
const uiState = createMockUIState({
|
||||
a2aListenerPort: 3000,
|
||||
sisyphusSecondsRemaining: 120, // 02:00
|
||||
});
|
||||
const { lastFrame, unmount } = await renderStatusDisplay(
|
||||
{ hideContextSummary: false },
|
||||
uiState,
|
||||
);
|
||||
expect(lastFrame()).toContain('⚡ A2A :3000');
|
||||
expect(lastFrame()).toContain('✦ Resuming work in 02:00');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
@@ -24,18 +24,42 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
|
||||
const items: React.ReactNode[] = [];
|
||||
|
||||
if (process.env['GEMINI_SYSTEM_MD']) {
|
||||
return <Text color={theme.status.error}>|⌐■_■|</Text>;
|
||||
items.push(<Text color={theme.status.error}>|⌐■_■|</Text>);
|
||||
}
|
||||
|
||||
if (
|
||||
uiState.activeHooks.length > 0 &&
|
||||
settings.merged.hooksConfig.notifications
|
||||
) {
|
||||
return <HookStatusDisplay activeHooks={uiState.activeHooks} />;
|
||||
items.push(<HookStatusDisplay activeHooks={uiState.activeHooks} />);
|
||||
}
|
||||
|
||||
if (!settings.merged.ui.hideContextSummary && !hideContextSummary) {
|
||||
if (uiState.a2aListenerPort !== null) {
|
||||
items.push(
|
||||
<Text color={theme.text.accent}>⚡ A2A :{uiState.a2aListenerPort}</Text>,
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.sisyphusSecondsRemaining !== null) {
|
||||
const mins = Math.floor(uiState.sisyphusSecondsRemaining / 60);
|
||||
const secs = uiState.sisyphusSecondsRemaining % 60;
|
||||
const timerStr = `${mins.toString().padStart(2, '0')}:${secs
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
items.push(
|
||||
<Text color={theme.text.accent}>✦ Resuming work in {timerStr}</Text>,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
items.length === 0 &&
|
||||
uiState.sisyphusSecondsRemaining === null &&
|
||||
!settings.merged.ui.hideContextSummary &&
|
||||
!hideContextSummary
|
||||
) {
|
||||
return (
|
||||
<ContextSummaryDisplay
|
||||
ideContext={uiState.ideContextState}
|
||||
@@ -51,5 +75,17 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
{items.map((item, index) => (
|
||||
<Box key={index} marginRight={index < items.length - 1 ? 1 : 0}>
|
||||
{item}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -188,7 +188,7 @@ describe('ToastDisplay', () => {
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(
|
||||
'Press Ctrl+O to show more lines of the last response',
|
||||
'Ctrl+O to show more lines of the last response',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export const ToastDisplay: React.FC = () => {
|
||||
const action = uiState.constrainHeight ? 'show more' : 'collapse';
|
||||
return (
|
||||
<Text color={theme.text.accent}>
|
||||
Press Ctrl+O to {action} lines of the last response
|
||||
Ctrl+O to {action} lines of the last response
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ 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';
|
||||
|
||||
@@ -42,6 +43,7 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
const { getPreferredEditor } = useUIActions();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const {
|
||||
mainAreaWidth,
|
||||
terminalHeight,
|
||||
@@ -155,5 +157,10 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
</>
|
||||
);
|
||||
|
||||
return <OverflowProvider>{content}</OverflowProvider>;
|
||||
return isAlternateBuffer ? (
|
||||
/* Shadow the global provider to maintain isolation in ASB mode. */
|
||||
<OverflowProvider>{content}</OverflowProvider>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] = `
|
||||
" workspace (/directory) sandbox /model /stats
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro limit reached
|
||||
" workspace (/directory) sandbox /model /stats
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro limit reached
|
||||
"
|
||||
`;
|
||||
|
||||
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 85%
|
||||
" workspace (/directory) sandbox /model /stats
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15%
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `
|
||||
" workspace (/directory) sandbox /model context
|
||||
...me/more/directories/to/make/it/long no sandbox gemini-pro 14%
|
||||
" workspace (/directory) sandbox /model context
|
||||
...me/more/directories/to/make/it/long no sandbox gemini-pro 14%
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `
|
||||
" workspace (/directory) sandbox /model context
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 14% used
|
||||
" workspace (/directory) sandbox /model context
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 14% used
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -33,13 +33,13 @@ exports[`<Footer /> > footer configuration filtering (golden snapshots) > render
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `
|
||||
" workspace (/directory) sandbox
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox
|
||||
" workspace (/directory) sandbox
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox
|
||||
"
|
||||
`;
|
||||
|
||||
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 15%
|
||||
" workspace (/directory) sandbox /model /stats
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85%
|
||||
"
|
||||
`;
|
||||
|
||||
-159
@@ -1,159 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="700" viewBox="0 0 920 700">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="700" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="27" y="36" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs" font-weight="bold">Configure Footer</text>
|
||||
<text x="891" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="27" y="70" fill="#afafaf" textLength="396" lengthAdjust="spacingAndGlyphs">Select which items to display in the footer.</text>
|
||||
<text x="891" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="104" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
|
||||
<text x="72" y="104" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs"> workspace</text>
|
||||
<text x="891" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="121" fill="#afafaf" textLength="234" lengthAdjust="spacingAndGlyphs"> Current working directory</text>
|
||||
<text x="891" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="138" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
|
||||
<text x="72" y="138" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs"> git-branch</text>
|
||||
<text x="891" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="155" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="155" fill="#afafaf" textLength="477" lengthAdjust="spacingAndGlyphs"> Current git branch name (not shown when unavailable)</text>
|
||||
<text x="891" y="155" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="172" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="172" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
|
||||
<text x="72" y="172" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs"> sandbox</text>
|
||||
<text x="891" y="172" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="189" fill="#afafaf" textLength="297" lengthAdjust="spacingAndGlyphs"> Sandbox type and trust indicator</text>
|
||||
<text x="891" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="206" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
|
||||
<text x="72" y="206" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs"> model-name</text>
|
||||
<text x="891" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="223" fill="#afafaf" textLength="225" lengthAdjust="spacingAndGlyphs"> Current model identifier</text>
|
||||
<text x="891" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="240" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
|
||||
<text x="72" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> quota</text>
|
||||
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Remaining usage on daily limit (not shown when unavailable)</text>
|
||||
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="274" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
|
||||
<text x="72" y="274" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> context-used</text>
|
||||
<text x="891" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="291" fill="#afafaf" textLength="306" lengthAdjust="spacingAndGlyphs"> Percentage of context window used</text>
|
||||
<text x="891" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="308" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
|
||||
<text x="72" y="308" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> memory-usage</text>
|
||||
<text x="891" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="279" lengthAdjust="spacingAndGlyphs"> Memory used by the application</text>
|
||||
<text x="891" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="342" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
|
||||
<text x="72" y="342" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs"> session-id</text>
|
||||
<text x="891" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="359" fill="#afafaf" textLength="378" lengthAdjust="spacingAndGlyphs"> Unique identifier for the current session</text>
|
||||
<text x="891" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<rect x="27" y="374" width="9" height="17" fill="#001a00" />
|
||||
<text x="27" y="376" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">></text>
|
||||
<rect x="36" y="374" width="9" height="17" fill="#001a00" />
|
||||
<rect x="45" y="374" width="27" height="17" fill="#001a00" />
|
||||
<text x="45" y="376" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
|
||||
<rect x="72" y="374" width="117" height="17" fill="#001a00" />
|
||||
<text x="72" y="376" fill="#00cd00" textLength="117" lengthAdjust="spacingAndGlyphs"> code-changes</text>
|
||||
<rect x="189" y="374" width="684" height="17" fill="#001a00" />
|
||||
<text x="891" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<rect x="27" y="391" width="18" height="17" fill="#001a00" />
|
||||
<rect x="45" y="391" width="513" height="17" fill="#001a00" />
|
||||
<text x="45" y="393" fill="#afafaf" textLength="513" lengthAdjust="spacingAndGlyphs"> Lines added/removed in the session (not shown when zero)</text>
|
||||
<rect x="558" y="391" width="315" height="17" fill="#001a00" />
|
||||
<text x="891" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="410" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
|
||||
<text x="72" y="410" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> token-count</text>
|
||||
<text x="891" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="427" fill="#afafaf" textLength="495" lengthAdjust="spacingAndGlyphs"> Total tokens used in the session (not shown when zero)</text>
|
||||
<text x="891" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="444" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
|
||||
<text x="72" y="444" fill="#ffffff" textLength="171" lengthAdjust="spacingAndGlyphs"> Show footer labels</text>
|
||||
<text x="891" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="478" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Reset to default footer</text>
|
||||
<text x="891" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="27" y="529" fill="#afafaf" textLength="585" lengthAdjust="spacingAndGlyphs">Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close</text>
|
||||
<text x="891" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="27" y="563" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">┌────────────────────────────────────────────────────────────────────────────────────────────┐</text>
|
||||
<text x="891" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="27" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="580" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs" font-weight="bold">Preview:</text>
|
||||
<text x="864" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="27" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="597" fill="#afafaf" textLength="198" lengthAdjust="spacingAndGlyphs">workspace (/directory)</text>
|
||||
<text x="288" y="597" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
|
||||
<text x="396" y="597" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
|
||||
<text x="504" y="597" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
|
||||
<text x="675" y="597" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/stats</text>
|
||||
<rect x="783" y="595" width="36" height="17" fill="#001a00" />
|
||||
<text x="783" y="597" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">diff</text>
|
||||
<rect x="819" y="595" width="36" height="17" fill="#001a00" />
|
||||
<text x="864" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="27" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="614" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
|
||||
<text x="288" y="614" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
|
||||
<text x="396" y="614" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
|
||||
<text x="504" y="614" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
|
||||
<text x="675" y="614" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
|
||||
<rect x="783" y="612" width="27" height="17" fill="#001a00" />
|
||||
<text x="783" y="614" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">+12</text>
|
||||
<rect x="810" y="612" width="9" height="17" fill="#001a00" />
|
||||
<rect x="819" y="612" width="18" height="17" fill="#001a00" />
|
||||
<text x="819" y="614" fill="#ff87af" textLength="18" lengthAdjust="spacingAndGlyphs">-4</text>
|
||||
<rect x="837" y="612" width="18" height="17" fill="#001a00" />
|
||||
<text x="864" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="27" y="631" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">└────────────────────────────────────────────────────────────────────────────────────────────┘</text>
|
||||
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="665" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 16 KiB |
-154
@@ -1,154 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="700" viewBox="0 0 920 700">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="700" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="27" y="36" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs" font-weight="bold">Configure Footer</text>
|
||||
<text x="891" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="27" y="70" fill="#afafaf" textLength="396" lengthAdjust="spacingAndGlyphs">Select which items to display in the footer.</text>
|
||||
<text x="891" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<rect x="27" y="102" width="9" height="17" fill="#001a00" />
|
||||
<text x="27" y="104" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">></text>
|
||||
<rect x="36" y="102" width="9" height="17" fill="#001a00" />
|
||||
<rect x="45" y="102" width="27" height="17" fill="#001a00" />
|
||||
<text x="45" y="104" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
|
||||
<rect x="72" y="102" width="90" height="17" fill="#001a00" />
|
||||
<text x="72" y="104" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs"> workspace</text>
|
||||
<rect x="162" y="102" width="711" height="17" fill="#001a00" />
|
||||
<text x="891" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<rect x="27" y="119" width="18" height="17" fill="#001a00" />
|
||||
<rect x="45" y="119" width="234" height="17" fill="#001a00" />
|
||||
<text x="45" y="121" fill="#afafaf" textLength="234" lengthAdjust="spacingAndGlyphs"> Current working directory</text>
|
||||
<rect x="279" y="119" width="594" height="17" fill="#001a00" />
|
||||
<text x="891" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="138" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
|
||||
<text x="72" y="138" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs"> git-branch</text>
|
||||
<text x="891" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="155" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="155" fill="#afafaf" textLength="477" lengthAdjust="spacingAndGlyphs"> Current git branch name (not shown when unavailable)</text>
|
||||
<text x="891" y="155" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="172" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="172" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
|
||||
<text x="72" y="172" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs"> sandbox</text>
|
||||
<text x="891" y="172" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="189" fill="#afafaf" textLength="297" lengthAdjust="spacingAndGlyphs"> Sandbox type and trust indicator</text>
|
||||
<text x="891" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="206" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
|
||||
<text x="72" y="206" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs"> model-name</text>
|
||||
<text x="891" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="223" fill="#afafaf" textLength="225" lengthAdjust="spacingAndGlyphs"> Current model identifier</text>
|
||||
<text x="891" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="240" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
|
||||
<text x="72" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> quota</text>
|
||||
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Remaining usage on daily limit (not shown when unavailable)</text>
|
||||
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="274" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
|
||||
<text x="72" y="274" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> context-used</text>
|
||||
<text x="891" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="291" fill="#afafaf" textLength="306" lengthAdjust="spacingAndGlyphs"> Percentage of context window used</text>
|
||||
<text x="891" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="308" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
|
||||
<text x="72" y="308" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> memory-usage</text>
|
||||
<text x="891" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="279" lengthAdjust="spacingAndGlyphs"> Memory used by the application</text>
|
||||
<text x="891" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="342" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
|
||||
<text x="72" y="342" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs"> session-id</text>
|
||||
<text x="891" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="359" fill="#afafaf" textLength="378" lengthAdjust="spacingAndGlyphs"> Unique identifier for the current session</text>
|
||||
<text x="891" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
|
||||
<text x="72" y="376" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> code-changes</text>
|
||||
<text x="891" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="393" fill="#afafaf" textLength="513" lengthAdjust="spacingAndGlyphs"> Lines added/removed in the session (not shown when zero)</text>
|
||||
<text x="891" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="410" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
|
||||
<text x="72" y="410" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> token-count</text>
|
||||
<text x="891" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="427" fill="#afafaf" textLength="495" lengthAdjust="spacingAndGlyphs"> Total tokens used in the session (not shown when zero)</text>
|
||||
<text x="891" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="444" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
|
||||
<text x="72" y="444" fill="#ffffff" textLength="171" lengthAdjust="spacingAndGlyphs"> Show footer labels</text>
|
||||
<text x="891" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="478" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Reset to default footer</text>
|
||||
<text x="891" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="27" y="529" fill="#afafaf" textLength="585" lengthAdjust="spacingAndGlyphs">Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close</text>
|
||||
<text x="891" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="27" y="563" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">┌────────────────────────────────────────────────────────────────────────────────────────────┐</text>
|
||||
<text x="891" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="27" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="580" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs" font-weight="bold">Preview:</text>
|
||||
<text x="864" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="27" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<rect x="45" y="595" width="198" height="17" fill="#001a00" />
|
||||
<text x="45" y="597" fill="#ffffff" textLength="198" lengthAdjust="spacingAndGlyphs">workspace (/directory)</text>
|
||||
<rect x="243" y="595" width="45" height="17" fill="#001a00" />
|
||||
<text x="315" y="597" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
|
||||
<text x="432" y="597" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
|
||||
<text x="567" y="597" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
|
||||
<text x="756" y="597" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/stats</text>
|
||||
<text x="864" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="27" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<rect x="45" y="612" width="126" height="17" fill="#001a00" />
|
||||
<text x="45" y="614" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
|
||||
<rect x="171" y="612" width="117" height="17" fill="#001a00" />
|
||||
<text x="315" y="614" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
|
||||
<text x="432" y="614" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
|
||||
<text x="567" y="614" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
|
||||
<text x="756" y="614" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
|
||||
<text x="864" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="27" y="631" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">└────────────────────────────────────────────────────────────────────────────────────────────┘</text>
|
||||
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="665" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 16 KiB |
@@ -1,48 +1,5 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<FooterConfigDialog /> > highlights the active item in the preview 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Configure Footer │
|
||||
│ │
|
||||
│ Select which items to display in the footer. │
|
||||
│ │
|
||||
│ [✓] workspace │
|
||||
│ Current working directory │
|
||||
│ [✓] git-branch │
|
||||
│ Current git branch name (not shown when unavailable) │
|
||||
│ [✓] sandbox │
|
||||
│ Sandbox type and trust indicator │
|
||||
│ [✓] model-name │
|
||||
│ Current model identifier │
|
||||
│ [✓] quota │
|
||||
│ Remaining usage on daily limit (not shown when unavailable) │
|
||||
│ [ ] context-used │
|
||||
│ Percentage of context window used │
|
||||
│ [ ] memory-usage │
|
||||
│ Memory used by the application │
|
||||
│ [ ] session-id │
|
||||
│ Unique identifier for the current session │
|
||||
│ > [✓] code-changes │
|
||||
│ Lines added/removed in the session (not shown when zero) │
|
||||
│ [ ] token-count │
|
||||
│ Total tokens used in the session (not shown when zero) │
|
||||
│ [✓] Show footer labels │
|
||||
│ │
|
||||
│ Reset to default footer │
|
||||
│ │
|
||||
│ │
|
||||
│ Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Preview: │ │
|
||||
│ │ workspace (/directory) branch sandbox /model /stats diff │ │
|
||||
│ │ ~/project/path main docker gemini-2.5-pro 97% +12 -4 │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
@@ -50,82 +7,28 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] =
|
||||
│ │
|
||||
│ Select which items to display in the footer. │
|
||||
│ │
|
||||
│ > [✓] workspace │
|
||||
│ Current working directory │
|
||||
│ [✓] git-branch │
|
||||
│ Current git branch name (not shown when unavailable) │
|
||||
│ [✓] sandbox │
|
||||
│ Sandbox type and trust indicator │
|
||||
│ [✓] model-name │
|
||||
│ Current model identifier │
|
||||
│ [✓] quota │
|
||||
│ Remaining usage on daily limit (not shown when unavailable) │
|
||||
│ [ ] context-used │
|
||||
│ Percentage of context window used │
|
||||
│ [ ] memory-usage │
|
||||
│ Memory used by the application │
|
||||
│ [ ] session-id │
|
||||
│ Unique identifier for the current session │
|
||||
│ [ ] code-changes │
|
||||
│ Lines added/removed in the session (not shown when zero) │
|
||||
│ [ ] token-count │
|
||||
│ Total tokens used in the session (not shown when zero) │
|
||||
│ [✓] Show footer labels │
|
||||
│ > [✓] workspace Current working directory │
|
||||
│ [✓] git-branch Current git branch name (not shown when unavailable) │
|
||||
│ [✓] sandbox Sandbox type and trust indicator │
|
||||
│ [✓] model-name Current model identifier │
|
||||
│ [✓] quota Remaining usage on daily limit (not shown when unavailable) │
|
||||
│ [ ] context-used Percentage of context window used │
|
||||
│ [ ] memory-usage Memory used by the application │
|
||||
│ [ ] session-id Unique identifier for the current session │
|
||||
│ [ ] code-changes Lines added/removed in the session (not shown when zero) │
|
||||
│ [ ] token-count Total tokens used in the session (not shown when zero) │
|
||||
│ │
|
||||
│ [✓] Show footer labels │
|
||||
│ Reset to default footer │
|
||||
│ │
|
||||
│ │
|
||||
│ Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close │
|
||||
│ ↑/↓ navigate · ←/→ reorder · enter/space select · esc close │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Preview: │ │
|
||||
│ │ workspace (/directory) branch sandbox /model /stats │ │
|
||||
│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
|
||||
│ │ workspace (/directory) branch sandbox /model /stats │ │
|
||||
│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<FooterConfigDialog /> > renders correctly with default settings 2`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Configure Footer │
|
||||
│ │
|
||||
│ Select which items to display in the footer. │
|
||||
│ │
|
||||
│ > [✓] workspace │
|
||||
│ Current working directory │
|
||||
│ [✓] git-branch │
|
||||
│ Current git branch name (not shown when unavailable) │
|
||||
│ [✓] sandbox │
|
||||
│ Sandbox type and trust indicator │
|
||||
│ [✓] model-name │
|
||||
│ Current model identifier │
|
||||
│ [✓] quota │
|
||||
│ Remaining usage on daily limit (not shown when unavailable) │
|
||||
│ [ ] context-used │
|
||||
│ Percentage of context window used │
|
||||
│ [ ] memory-usage │
|
||||
│ Memory used by the application │
|
||||
│ [ ] session-id │
|
||||
│ Unique identifier for the current session │
|
||||
│ [ ] code-changes │
|
||||
│ Lines added/removed in the session (not shown when zero) │
|
||||
│ [ ] token-count │
|
||||
│ Total tokens used in the session (not shown when zero) │
|
||||
│ [✓] Show footer labels │
|
||||
│ │
|
||||
│ Reset to default footer │
|
||||
│ │
|
||||
│ │
|
||||
│ Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Preview: │ │
|
||||
│ │ workspace (/directory) branch sandbox /model /stats │ │
|
||||
│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -18,6 +18,7 @@ AppHeader(full)
|
||||
│ Line 19 █ │
|
||||
│ Line 20 █ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Press Ctrl+O to show more lines
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -39,6 +40,7 @@ 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`] = `
|
||||
"85%
|
||||
"15%
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`QuotaDisplay > should render critical when used >= 95% 1`] = `
|
||||
"96% used
|
||||
exports[`QuotaDisplay > should render red when usage < 5% 1`] = `
|
||||
"/stats 4% usage remaining
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -15,12 +15,12 @@ exports[`QuotaDisplay > should render terse limit reached message 1`] = `
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`QuotaDisplay > should render warning when used >= 80% 1`] = `
|
||||
"85% used
|
||||
exports[`QuotaDisplay > should render with reset time when provided 1`] = `
|
||||
"/stats 15% usage remaining, resets in 1h
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`QuotaDisplay > should render with reset time when provided 1`] = `
|
||||
"85% used (Limit resets in 1h)
|
||||
exports[`QuotaDisplay > should render yellow when usage < 20% 1`] = `
|
||||
"/stats 15% usage remaining
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -17,9 +17,10 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
|
||||
│ » API Time: 50.2s (100.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ Model Reqs Input Tokens Cache Reads Output Tokens │
|
||||
│ ──────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 10 500 500 2,000 │
|
||||
│ Model Usage │
|
||||
│ 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,9 +117,10 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency secti
|
||||
│ » API Time: 100ms (100.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ Model Reqs Input Tokens Cache Reads Output Tokens │
|
||||
│ ──────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 1 100 0 100 │
|
||||
│ Model Usage │
|
||||
│ Model Reqs Input Tokens Cache Reads Output Tokens │
|
||||
│ ──────────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 1 100 0 100 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
@@ -161,15 +162,16 @@ exports[`<StatsDisplay /> > Quota Display > renders pooled quota information for
|
||||
│ » API Time: 0s (0.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ 35% used │
|
||||
│ auto Usage │
|
||||
│ 65% usage remaining │
|
||||
│ Usage limit: 1,100 │
|
||||
│ Usage limits span all sessions and reset daily. │
|
||||
│ For a full token breakdown, run \`/stats model\`. │
|
||||
│ │
|
||||
│ Model Reqs Model usage Usage resets │
|
||||
│ ──────────────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 90% │
|
||||
│ gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 30% │
|
||||
│ Model Reqs Usage remaining │
|
||||
│ ──────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro - │
|
||||
│ gemini-2.5-flash - │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
@@ -191,9 +193,10 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information for unused
|
||||
│ » API Time: 0s (0.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ Model Reqs Model usage Usage resets │
|
||||
│ ──────────────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 50% 2:00 PM (2h) │
|
||||
│ Model Usage │
|
||||
│ Model Reqs Usage remaining │
|
||||
│ ──────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-flash - 50.0% resets in 2h │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
@@ -215,9 +218,10 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information when quota
|
||||
│ » API Time: 100ms (100.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ Model Reqs Model usage Usage resets │
|
||||
│ ──────────────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 1 ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 25% 1:30 PM (1h 30m) │
|
||||
│ Model Usage │
|
||||
│ Model Reqs Usage remaining │
|
||||
│ ──────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 1 75.0% resets in 1h 30m │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
@@ -279,10 +283,11 @@ exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
|
||||
│ » API Time: 19.5s (100.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ 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 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 │
|
||||
│ │
|
||||
│ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │
|
||||
│ │
|
||||
@@ -307,9 +312,10 @@ exports[`<StatsDisplay /> > renders all sections when all data is present 1`] =
|
||||
│ » API Time: 100ms (44.8%) │
|
||||
│ » Tool Time: 123ms (55.2%) │
|
||||
│ │
|
||||
│ Model Reqs Input Tokens Cache Reads Output Tokens │
|
||||
│ ──────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 1 50 50 100 │
|
||||
│ Model Usage │
|
||||
│ 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. │
|
||||
│ │
|
||||
|
||||
@@ -16,7 +16,6 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai
|
||||
│ 4. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Press Ctrl+O to show more lines
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export function CompressionMessage({
|
||||
|
||||
const originalTokens = originalTokenCount ?? 0;
|
||||
const newTokens = newTokenCount ?? 0;
|
||||
const archivePath = compression.archivePath;
|
||||
|
||||
const getCompressionText = () => {
|
||||
if (isPending) {
|
||||
@@ -36,6 +37,8 @@ export function CompressionMessage({
|
||||
switch (compressionStatus) {
|
||||
case CompressionStatus.COMPRESSED:
|
||||
return `Chat history compressed from ${originalTokens} to ${newTokens} tokens.`;
|
||||
case CompressionStatus.ARCHIVED:
|
||||
return `Chat history archived to ${archivePath} (${originalTokens} to ${newTokens} tokens).`;
|
||||
case CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT:
|
||||
// For smaller histories (< 50k tokens), compression overhead likely exceeds benefits
|
||||
if (originalTokens < 50000) {
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
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;
|
||||
@@ -28,7 +31,8 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
const prefix = '✦ ';
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const content = (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={theme.text.accent} aria-label={SCREEN_READER_MODEL_PREFIX}>
|
||||
@@ -40,14 +44,26 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
availableTerminalHeight === undefined
|
||||
isAlternateBuffer || 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,7 +7,9 @@
|
||||
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;
|
||||
@@ -29,6 +31,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const { renderMarkdown } = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const originalPrefix = '✦ ';
|
||||
const prefixWidth = originalPrefix.length;
|
||||
|
||||
@@ -38,13 +41,18 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
availableTerminalHeight === undefined
|
||||
isAlternateBuffer || 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 - 3,
|
||||
ACTIVE_SHELL_MAX_LINES,
|
||||
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 - 3,
|
||||
ACTIVE_SHELL_MAX_LINES,
|
||||
false,
|
||||
],
|
||||
])('%s', async (_, availableTerminalHeight, expectedMaxLines, focused) => {
|
||||
@@ -301,8 +301,8 @@ describe('<ShellToolMessage />', () => {
|
||||
await waitUntilReady();
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
// Should still be constrained to 12 (15 - 3) because isExpandable is false
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(12);
|
||||
// Should still be constrained to ACTIVE_SHELL_MAX_LINES (15) because isExpandable is false
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(15);
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
|
||||
@@ -24,16 +24,8 @@ 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,
|
||||
ShellExecutionService,
|
||||
CoreToolCallStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
calculateShellMaxLines,
|
||||
calculateToolContentMaxLines,
|
||||
SHELL_CONTENT_OVERHEAD,
|
||||
} from '../../utils/toolLayoutUtils.js';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { calculateShellMaxLines } from '../../utils/toolLayoutUtils.js';
|
||||
|
||||
export interface ShellToolMessageProps extends ToolMessageProps {
|
||||
config?: Config;
|
||||
@@ -86,47 +78,6 @@ 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);
|
||||
|
||||
@@ -215,7 +166,14 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
terminalWidth={terminalWidth}
|
||||
renderOutputAsMarkdown={renderOutputAsMarkdown}
|
||||
hasFocus={isThisShellFocused}
|
||||
maxLines={maxLines}
|
||||
maxLines={calculateShellMaxLines({
|
||||
status,
|
||||
isAlternateBuffer,
|
||||
isThisShellFocused,
|
||||
availableTerminalHeight,
|
||||
constrainHeight,
|
||||
isExpandable,
|
||||
})}
|
||||
/>
|
||||
{isThisShellFocused && config && (
|
||||
<ShellInputPrompt
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { ToolGroupMessage } from './ToolGroupMessage.js';
|
||||
import type {
|
||||
HistoryItem,
|
||||
@@ -766,4 +767,200 @@ 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,12 +17,18 @@ import { ToolMessage } from './ToolMessage.js';
|
||||
import { ShellToolMessage } from './ShellToolMessage.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { isShellTool } from './ToolShared.js';
|
||||
import { isShellTool, isThisShellFocused } from './ToolShared.js';
|
||||
import {
|
||||
shouldHideToolCall,
|
||||
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';
|
||||
|
||||
@@ -77,11 +83,13 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
|
||||
const config = useConfig();
|
||||
const {
|
||||
constrainHeight,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
backgroundShells,
|
||||
pendingHistoryItems,
|
||||
} = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const { borderColor, borderDimColor } = useMemo(
|
||||
() =>
|
||||
@@ -141,6 +149,72 @@ 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;
|
||||
@@ -233,6 +307,12 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
/>
|
||||
)
|
||||
}
|
||||
{(borderBottomOverride ?? true) && visibleToolCalls.length > 0 && (
|
||||
<ShowMoreLines
|
||||
constrainHeight={constrainHeight && !!isExpandable}
|
||||
isOverflowing={hasOverflow}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
|
||||
@@ -98,7 +98,6 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
status={status}
|
||||
description={description}
|
||||
emphasis={emphasis}
|
||||
progressMessage={progressMessage}
|
||||
originalRequestName={originalRequestName}
|
||||
/>
|
||||
<FocusHint
|
||||
|
||||
@@ -8,19 +8,21 @@ 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 ToolGroupMessage correctly reports overflow to the global state in Alternate Buffer (ASB) mode', async () => {
|
||||
it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Alternate Buffer (ASB) mode', async () => {
|
||||
/**
|
||||
* Logic:
|
||||
* 1. availableTerminalHeight(13) - staticHeight(1) - ASB Reserved(6) = 6 lines per tool.
|
||||
* 2. 10 lines of output > 6 lines budget => hasOverflow should be TRUE.
|
||||
* 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool.
|
||||
* 2. ASB mode reserves 1 + 6 = 7 lines.
|
||||
* 3. Line budget = 10 - 7 = 3 lines.
|
||||
* 4. 5 lines of output > 3 lines budget => hasOverflow should be TRUE.
|
||||
*/
|
||||
|
||||
const lines = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`);
|
||||
const lines = Array.from({ length: 5 }, (_, i) => `line ${i + 1}`);
|
||||
const resultDisplay = lines.join('\n');
|
||||
|
||||
const toolCalls: IndividualToolCallDisplay[] = [
|
||||
@@ -34,15 +36,8 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
|
||||
},
|
||||
];
|
||||
|
||||
let latestOverflowState: ReturnType<typeof useOverflowState>;
|
||||
const StateCapture = () => {
|
||||
latestOverflowState = useOverflowState();
|
||||
return null;
|
||||
};
|
||||
|
||||
const { unmount, waitUntilReady } = renderWithProviders(
|
||||
<>
|
||||
<StateCapture />
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<ToolGroupMessage
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
@@ -50,7 +45,7 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
|
||||
terminalWidth={80}
|
||||
isExpandable={true}
|
||||
/>
|
||||
</>,
|
||||
</OverflowProvider>,
|
||||
{
|
||||
uiState: {
|
||||
streamingState: StreamingState.Idle,
|
||||
@@ -60,26 +55,24 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
// To verify that the overflow state was indeed updated by the Scrollable component.
|
||||
await waitFor(() => {
|
||||
expect(latestOverflowState?.overflowingIds.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
unmount();
|
||||
// In ASB mode, the hint should appear because hasOverflow is now correctly calculated.
|
||||
await waitFor(() =>
|
||||
expect(lastFrame()?.toLowerCase()).toContain(
|
||||
'press ctrl+o to show more lines',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should ensure ToolGroupMessage correctly reports overflow in Standard mode', async () => {
|
||||
it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Standard mode', async () => {
|
||||
/**
|
||||
* Logic:
|
||||
* 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.
|
||||
* 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool.
|
||||
* 2. Standard mode reserves 1 + 2 = 3 lines.
|
||||
* 3. Line budget = 10 - 3 = 7 lines.
|
||||
* 4. 9 lines of output > 7 lines budget => hasOverflow should be TRUE.
|
||||
*/
|
||||
|
||||
const lines = Array.from({ length: 15 }, (_, i) => `line ${i + 1}`);
|
||||
const lines = Array.from({ length: 9 }, (_, i) => `line ${i + 1}`);
|
||||
const resultDisplay = lines.join('\n');
|
||||
|
||||
const toolCalls: IndividualToolCallDisplay[] = [
|
||||
@@ -93,14 +86,16 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={13}
|
||||
terminalWidth={80}
|
||||
isExpandable={true}
|
||||
/>,
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<ToolGroupMessage
|
||||
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={13}
|
||||
terminalWidth={80}
|
||||
isExpandable={true}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{
|
||||
uiState: {
|
||||
streamingState: StreamingState.Idle,
|
||||
@@ -110,11 +105,11 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
// Verify truncation is occurring (standard mode uses MaxSizedBox)
|
||||
await waitFor(() => expect(lastFrame()).toContain('hidden (Ctrl+O'));
|
||||
|
||||
unmount();
|
||||
// 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.
|
||||
});
|
||||
});
|
||||
|
||||
@@ -236,7 +236,6 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
maxHeight={maxLines ?? availableHeight}
|
||||
hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down)
|
||||
scrollToBottom={true}
|
||||
reportOverflow={true}
|
||||
>
|
||||
{content}
|
||||
</Scrollable>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @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,7 +192,6 @@ type ToolInfoProps = {
|
||||
description: string;
|
||||
status: CoreToolCallStatus;
|
||||
emphasis: TextEmphasis;
|
||||
progressMessage?: string;
|
||||
originalRequestName?: string;
|
||||
};
|
||||
|
||||
@@ -201,7 +200,6 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
|
||||
description,
|
||||
status: coreStatus,
|
||||
emphasis,
|
||||
progressMessage: _progressMessage,
|
||||
originalRequestName,
|
||||
}) => {
|
||||
const status = mapCoreStatusToDisplayStatus(coreStatus);
|
||||
|
||||
+15
-6
@@ -4,6 +4,9 @@ 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 │
|
||||
@@ -13,8 +16,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 █ │
|
||||
"
|
||||
`;
|
||||
@@ -145,6 +148,9 @@ 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 │
|
||||
@@ -154,8 +160,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 █ │
|
||||
"
|
||||
`;
|
||||
@@ -164,6 +170,9 @@ 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 │
|
||||
@@ -173,8 +182,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 █ │
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -33,7 +33,6 @@ export interface BaseSelectionListProps<
|
||||
wrapAround?: boolean;
|
||||
focusKey?: string;
|
||||
priority?: boolean;
|
||||
selectedIndicator?: string;
|
||||
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -66,7 +65,6 @@ export function BaseSelectionList<
|
||||
wrapAround = true,
|
||||
focusKey,
|
||||
priority,
|
||||
selectedIndicator = '●',
|
||||
renderItem,
|
||||
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
|
||||
const { activeIndex } = useSelectionList({
|
||||
@@ -150,7 +148,7 @@ export function BaseSelectionList<
|
||||
color={isSelected ? theme.ui.focus : theme.text.primary}
|
||||
aria-hidden
|
||||
>
|
||||
{isSelected ? selectedIndicator : ' '}
|
||||
{isSelected ? '●' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -5,22 +5,13 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useLayoutEffect,
|
||||
useEffect,
|
||||
useId,
|
||||
} from 'react';
|
||||
import { useState, useRef, useCallback, useMemo, useLayoutEffect } 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;
|
||||
@@ -31,7 +22,6 @@ interface ScrollableProps {
|
||||
hasFocus: boolean;
|
||||
scrollToBottom?: boolean;
|
||||
flexGrow?: number;
|
||||
reportOverflow?: boolean;
|
||||
}
|
||||
|
||||
export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
@@ -43,13 +33,10 @@ 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,
|
||||
@@ -65,27 +52,6 @@ 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 reload loads context files (/settings)…',
|
||||
'Control how /memory refresh 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…',
|
||||
'Reload MCP servers with /mcp reload…',
|
||||
'Restart MCP servers with /mcp refresh…',
|
||||
'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 reload…',
|
||||
'Reload instructional context from GEMINI.md files with /memory refresh…',
|
||||
'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…',
|
||||
|
||||
@@ -227,6 +227,8 @@ export interface UIState {
|
||||
text: string;
|
||||
type: TransientMessageType;
|
||||
} | null;
|
||||
sisyphusSecondsRemaining: number | null;
|
||||
a2aListenerPort: number | null;
|
||||
}
|
||||
|
||||
export const UIStateContext = createContext<UIState | null>(null);
|
||||
|
||||
@@ -502,9 +502,7 @@ 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,6 +325,7 @@ 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,
|
||||
|
||||
@@ -297,8 +297,13 @@ describe('useGeminiStream', () => {
|
||||
})),
|
||||
getIdeMode: vi.fn(() => false),
|
||||
getEnableHooks: vi.fn(() => false),
|
||||
getIsForeverMode: vi.fn(() => false),
|
||||
getSisyphusMode: vi.fn(() => ({
|
||||
enabled: false,
|
||||
idleTimeout: 1,
|
||||
prompt: 'continue workflow',
|
||||
})),
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); // Clear mocks before each test
|
||||
mockAddItem = vi.fn();
|
||||
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
buildUserSteeringHintPrompt,
|
||||
GeminiCliOperation,
|
||||
getPlanModeExitMessage,
|
||||
CompressionStatus,
|
||||
SCHEDULE_WORK_TOOL_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Config,
|
||||
@@ -58,7 +60,6 @@ import type {
|
||||
HistoryItemThinking,
|
||||
HistoryItemWithoutId,
|
||||
HistoryItemToolGroup,
|
||||
HistoryItemInfo,
|
||||
IndividualToolCallDisplay,
|
||||
SlashCommandProcessorResult,
|
||||
HistoryItemModel,
|
||||
@@ -229,6 +230,27 @@ export const useGeminiStream = (
|
||||
const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] =
|
||||
useStateAndRef<boolean>(true);
|
||||
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Sisyphus Mode States
|
||||
const activeSisyphusScheduleRef = useRef<{
|
||||
breakTime?: number;
|
||||
prompt?: string;
|
||||
isExplicitSchedule?: boolean;
|
||||
} | null>(null);
|
||||
const sisyphusTargetTimestampRef = useRef<number | null>(null);
|
||||
const [sisyphusSecondsRemaining, setSisyphusSecondsRemaining] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [, setSisyphusTick] = useState<number>(0);
|
||||
const submitQueryRef = useRef<
|
||||
(
|
||||
query: PartListUnion,
|
||||
options?: { isContinuation: boolean },
|
||||
prompt_id?: string,
|
||||
) => Promise<void>
|
||||
>(() => Promise.resolve());
|
||||
const hasForcedConfuciusRef = useRef<boolean>(false);
|
||||
|
||||
const { startNewPrompt, getPromptCount } = useSessionStats();
|
||||
const storage = config.storage;
|
||||
const logger = useLogger(storage);
|
||||
@@ -1060,31 +1082,37 @@ export const useGeminiStream = (
|
||||
eventValue: ServerGeminiChatCompressedEvent['value'],
|
||||
userMessageTimestamp: number,
|
||||
) => {
|
||||
// Reset the force flag so Confucius can trigger again before the NEXT compression cycle
|
||||
hasForcedConfuciusRef.current = false;
|
||||
|
||||
if (pendingHistoryItemRef.current) {
|
||||
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
||||
setPendingHistoryItem(null);
|
||||
}
|
||||
const isArchived =
|
||||
eventValue?.compressionStatus === CompressionStatus.ARCHIVED;
|
||||
const archivePath = eventValue?.archivePath;
|
||||
|
||||
const limit = tokenLimit(config.getModel());
|
||||
const originalPercentage = Math.round(
|
||||
((eventValue?.originalTokenCount ?? 0) / limit) * 100,
|
||||
);
|
||||
const newPercentage = Math.round(
|
||||
((eventValue?.newTokenCount ?? 0) / limit) * 100,
|
||||
);
|
||||
let text =
|
||||
`IMPORTANT: This conversation exceeded the compress threshold. ` +
|
||||
`A compressed context will be sent for future messages (compressed from: ` +
|
||||
`${eventValue?.originalTokenCount ?? 'unknown'} to ` +
|
||||
`${eventValue?.newTokenCount ?? 'unknown'} tokens).`;
|
||||
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Context compressed from ${originalPercentage}% to ${newPercentage}%.`,
|
||||
secondaryText: `Change threshold in /settings.`,
|
||||
color: theme.status.warning,
|
||||
marginBottom: 1,
|
||||
} as HistoryItemInfo,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
if (isArchived && archivePath) {
|
||||
text =
|
||||
`IMPORTANT: This conversation exceeded the compress threshold. ` +
|
||||
`History has been archived to: ${archivePath} (compressed from: ` +
|
||||
`${eventValue?.originalTokenCount ?? 'unknown'} to ` +
|
||||
`${eventValue?.newTokenCount ?? 'unknown'} tokens).`;
|
||||
}
|
||||
|
||||
return addItem({
|
||||
type: 'info',
|
||||
text,
|
||||
});
|
||||
},
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem, config],
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
|
||||
);
|
||||
|
||||
const handleMaxSessionTurnsEvent = useCallback(
|
||||
@@ -1248,6 +1276,17 @@ export const useGeminiStream = (
|
||||
);
|
||||
break;
|
||||
case ServerGeminiEventType.ToolCallRequest:
|
||||
if (event.value.name === SCHEDULE_WORK_TOOL_NAME) {
|
||||
const args = event.value.args;
|
||||
const inMinutes = Number(args?.['inMinutes'] ?? 0);
|
||||
activeSisyphusScheduleRef.current = {
|
||||
breakTime: inMinutes,
|
||||
isExplicitSchedule: true,
|
||||
};
|
||||
setSisyphusSecondsRemaining(inMinutes * 60);
|
||||
// Do NOT intercept and manually resolve it here.
|
||||
// Push it to toolCallRequests so it is executed properly by the backend tool registry.
|
||||
}
|
||||
toolCallRequests.push(event.value);
|
||||
break;
|
||||
case ServerGeminiEventType.UserCancelled:
|
||||
@@ -1369,6 +1408,10 @@ export const useGeminiStream = (
|
||||
|
||||
const userMessageTimestamp = Date.now();
|
||||
|
||||
// Reset Sisyphus timer on any activity but preserve the active schedule override if it exists
|
||||
setSisyphusSecondsRemaining(null);
|
||||
sisyphusTargetTimestampRef.current = null;
|
||||
|
||||
// Reset quota error flag when starting a new query (not a continuation)
|
||||
if (!options?.isContinuation) {
|
||||
setModelSwitchedFromQuotaError(false);
|
||||
@@ -1388,6 +1431,35 @@ export const useGeminiStream = (
|
||||
if (!prompt_id) {
|
||||
prompt_id = config.getSessionId() + '########' + getPromptCount();
|
||||
}
|
||||
|
||||
if (config.getIsForeverMode()) {
|
||||
const currentTokens = geminiClient
|
||||
.getChat()
|
||||
.getLastPromptTokenCount();
|
||||
const threshold = (await config.getCompressionThreshold()) ?? 0.8;
|
||||
const limit = tokenLimit(config.getActiveModel());
|
||||
|
||||
if (
|
||||
currentTokens >= limit * threshold * 0.9 &&
|
||||
!hasForcedConfuciusRef.current
|
||||
) {
|
||||
hasForcedConfuciusRef.current = true;
|
||||
const hippocampusContent = config.getHippocampusContent().trim();
|
||||
const hippocampusBlock = hippocampusContent
|
||||
? `\n\nThe following is the short-term memory (hippocampus) that MUST be passed to the confucius agent as the query input:\n--- Hippocampus ---\n${hippocampusContent}\n-------------------`
|
||||
: '';
|
||||
const confuciusNudge = `\n<system_note>\nYour context window is approaching the compression threshold. Before responding to the user's request, you MUST first call the 'confucius' tool to consolidate important learnings from this session into long-term knowledge.${hippocampusBlock}\n\nAfter the confucius agent completes, proceed with the user's original request.\n</system_note>\n`;
|
||||
if (typeof query === 'string') {
|
||||
query = [{ text: query }, { text: confuciusNudge }];
|
||||
} else if (Array.isArray(query)) {
|
||||
query = [...query, { text: confuciusNudge }];
|
||||
} else {
|
||||
// Single Part object
|
||||
query = [query, { text: confuciusNudge }];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return promptIdContext.run(prompt_id, async () => {
|
||||
const { queryToSend, shouldProceed } = await prepareQueryForGemini(
|
||||
query,
|
||||
@@ -1448,6 +1520,7 @@ export const useGeminiStream = (
|
||||
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
||||
setPendingHistoryItem(null);
|
||||
}
|
||||
|
||||
if (loopDetectedRef.current) {
|
||||
loopDetectedRef.current = false;
|
||||
// Show the confirmation dialog to choose whether to disable loop detection
|
||||
@@ -1874,6 +1947,98 @@ export const useGeminiStream = (
|
||||
storage,
|
||||
]);
|
||||
|
||||
// Handle Sisyphus countdown and automatic trigger
|
||||
useEffect(() => {
|
||||
submitQueryRef.current = submitQuery;
|
||||
}, [submitQuery]);
|
||||
|
||||
// Handle Sisyphus activation and automatic trigger
|
||||
useEffect(() => {
|
||||
const sisyphusSettings = config.getSisyphusMode();
|
||||
const isExplicitlyScheduled =
|
||||
activeSisyphusScheduleRef.current?.isExplicitSchedule;
|
||||
|
||||
if (!sisyphusSettings.enabled && !isExplicitlyScheduled) {
|
||||
setSisyphusSecondsRemaining(null);
|
||||
sisyphusTargetTimestampRef.current = null;
|
||||
activeSisyphusScheduleRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (streamingState !== StreamingState.Idle) {
|
||||
setSisyphusSecondsRemaining(null);
|
||||
sisyphusTargetTimestampRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Now we are IDLE. If no target is set, set one.
|
||||
if (sisyphusTargetTimestampRef.current === null) {
|
||||
if (
|
||||
!activeSisyphusScheduleRef.current &&
|
||||
sisyphusSettings.idleTimeout !== undefined
|
||||
) {
|
||||
activeSisyphusScheduleRef.current = {
|
||||
breakTime: sisyphusSettings.idleTimeout,
|
||||
prompt: sisyphusSettings.prompt,
|
||||
};
|
||||
}
|
||||
|
||||
if (activeSisyphusScheduleRef.current?.breakTime !== undefined) {
|
||||
const delayMs = activeSisyphusScheduleRef.current.breakTime * 60 * 1000;
|
||||
sisyphusTargetTimestampRef.current = Date.now() + delayMs;
|
||||
setSisyphusSecondsRemaining(Math.ceil(delayMs / 1000));
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
streamingState === StreamingState.Idle &&
|
||||
sisyphusSecondsRemaining !== null &&
|
||||
sisyphusSecondsRemaining <= 0
|
||||
) {
|
||||
const isExplicitSchedule =
|
||||
activeSisyphusScheduleRef.current?.isExplicitSchedule;
|
||||
const promptToUse = isExplicitSchedule
|
||||
? 'System: The scheduled break has ended. Please resume your work.'
|
||||
: (activeSisyphusScheduleRef.current?.prompt ??
|
||||
sisyphusSettings.prompt ??
|
||||
'continue workflow');
|
||||
|
||||
// Clear for next time so it reverts to default
|
||||
activeSisyphusScheduleRef.current = null;
|
||||
sisyphusTargetTimestampRef.current = null;
|
||||
setSisyphusSecondsRemaining(null);
|
||||
void submitQueryRef.current(promptToUse);
|
||||
}
|
||||
}, [streamingState, sisyphusSecondsRemaining, config]);
|
||||
|
||||
// Handle Sisyphus countdown timers independently to ensure UI updates
|
||||
const isTimerActive =
|
||||
(streamingState === StreamingState.Idle &&
|
||||
sisyphusTargetTimestampRef.current !== null) ||
|
||||
config.getSisyphusMode().enabled ||
|
||||
activeSisyphusScheduleRef.current?.isExplicitSchedule;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTimerActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateTimer = () => {
|
||||
// Sisyphus countdown
|
||||
if (sisyphusTargetTimestampRef.current !== null) {
|
||||
const remainingMs = sisyphusTargetTimestampRef.current - Date.now();
|
||||
const remainingSecs = Math.max(0, Math.ceil(remainingMs / 1000));
|
||||
setSisyphusSecondsRemaining(remainingSecs);
|
||||
}
|
||||
|
||||
setSisyphusTick((t) => t + 1); // Force a re-render
|
||||
};
|
||||
|
||||
const timer = setInterval(updateTimer, 100); // Update frequently for high responsiveness
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isTimerActive, config]);
|
||||
|
||||
const lastOutputTime = Math.max(
|
||||
lastToolOutputTime,
|
||||
lastShellOutputTime,
|
||||
@@ -1899,5 +2064,6 @@ export const useGeminiStream = (
|
||||
backgroundShells,
|
||||
dismissBackgroundShell,
|
||||
retryStatus,
|
||||
sisyphusSecondsRemaining,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -81,8 +81,6 @@ describe('useSelectionList', () => {
|
||||
isFocused?: boolean;
|
||||
showNumbers?: boolean;
|
||||
wrapAround?: boolean;
|
||||
focusKey?: string;
|
||||
priority?: boolean;
|
||||
}) => {
|
||||
let hookResult: ReturnType<typeof useSelectionList>;
|
||||
function TestComponent(props: typeof initialProps) {
|
||||
@@ -773,67 +771,6 @@ describe('useSelectionList', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Programmatic Focus (focusKey)', () => {
|
||||
it('should change the activeIndex when a valid focusKey is provided', async () => {
|
||||
const { result, rerender, waitUntilReady } =
|
||||
await renderSelectionListHook({
|
||||
items,
|
||||
onSelect: mockOnSelect,
|
||||
});
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
|
||||
await rerender({ focusKey: 'C' });
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
});
|
||||
|
||||
it('should ignore a focusKey that does not exist', async () => {
|
||||
const { result, rerender, waitUntilReady } =
|
||||
await renderSelectionListHook({
|
||||
items,
|
||||
onSelect: mockOnSelect,
|
||||
});
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
|
||||
await rerender({ focusKey: 'UNKNOWN' });
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('should ignore a focusKey that points to a disabled item', async () => {
|
||||
const { result, rerender, waitUntilReady } =
|
||||
await renderSelectionListHook({
|
||||
items, // B is disabled
|
||||
onSelect: mockOnSelect,
|
||||
});
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
|
||||
await rerender({ focusKey: 'B' });
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle clearing the focusKey', async () => {
|
||||
const { result, rerender, waitUntilReady } =
|
||||
await renderSelectionListHook({
|
||||
items,
|
||||
onSelect: mockOnSelect,
|
||||
focusKey: 'C',
|
||||
});
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
|
||||
await rerender({ focusKey: undefined });
|
||||
await waitUntilReady();
|
||||
// Should remain at 2
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
|
||||
// We can then change it again to something else
|
||||
await rerender({ focusKey: 'D' });
|
||||
await waitUntilReady();
|
||||
expect(result.current.activeIndex).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reactivity (Dynamic Updates)', () => {
|
||||
it('should update activeIndex when initialIndex prop changes', async () => {
|
||||
const { result, rerender } = await renderSelectionListHook({
|
||||
|
||||
@@ -213,7 +213,8 @@ function selectionListReducer(
|
||||
case 'INITIALIZE': {
|
||||
const { initialIndex, items, wrapAround } = action.payload;
|
||||
const activeKey =
|
||||
initialIndex === state.initialIndex
|
||||
initialIndex === state.initialIndex &&
|
||||
state.activeIndex !== state.initialIndex
|
||||
? state.items[state.activeIndex]?.key
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -76,12 +76,17 @@ export const useSessionBrowser = (
|
||||
|
||||
// We've loaded it; tell the UI about it.
|
||||
setIsSessionBrowserOpen(false);
|
||||
const compressionIndex = conversation.lastCompressionIndex;
|
||||
const historyData = convertSessionToHistoryFormats(
|
||||
conversation.messages,
|
||||
compressionIndex,
|
||||
);
|
||||
await onLoadHistory(
|
||||
historyData.uiHistory,
|
||||
convertSessionToClientHistory(conversation.messages),
|
||||
convertSessionToClientHistory(
|
||||
conversation.messages,
|
||||
compressionIndex,
|
||||
),
|
||||
resumedSessionData,
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -109,12 +109,18 @@ export function useSessionResume({
|
||||
!hasLoadedResumedSession.current
|
||||
) {
|
||||
hasLoadedResumedSession.current = true;
|
||||
const compressionIndex =
|
||||
resumedSessionData.conversation.lastCompressionIndex;
|
||||
const historyData = convertSessionToHistoryFormats(
|
||||
resumedSessionData.conversation.messages,
|
||||
compressionIndex,
|
||||
);
|
||||
void loadHistoryForResume(
|
||||
historyData.uiHistory,
|
||||
convertSessionToClientHistory(resumedSessionData.conversation.messages),
|
||||
convertSessionToClientHistory(
|
||||
resumedSessionData.conversation.messages,
|
||||
compressionIndex,
|
||||
),
|
||||
resumedSessionData,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,20 +117,6 @@ 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));
|
||||
@@ -165,6 +151,20 @@ 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);
|
||||
|
||||
@@ -122,6 +122,7 @@ export interface CompressionProps {
|
||||
originalTokenCount: number | null;
|
||||
newTokenCount: number | null;
|
||||
compressionStatus: CompressionStatus | null;
|
||||
archivePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,9 +19,6 @@ 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,
|
||||
@@ -39,19 +36,3 @@ 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,45 +10,9 @@ 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,58 +98,26 @@ export function stripReferenceContent(text: string): string {
|
||||
return text.replace(pattern, '').trim();
|
||||
}
|
||||
|
||||
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();
|
||||
export const formatResetTime = (resetTime: string): string => {
|
||||
const diff = new Date(resetTime).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 isTerse = format === 'terse';
|
||||
const isColumn = format === 'column';
|
||||
const fmt = (val: number, unit: 'hour' | 'minute') =>
|
||||
new Intl.NumberFormat('en', {
|
||||
style: 'unit',
|
||||
unit,
|
||||
unitDisplay: 'narrow',
|
||||
}).format(val);
|
||||
|
||||
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;
|
||||
if (hours > 0 && minutes > 0) {
|
||||
return `resets in ${fmt(hours, 'hour')} ${fmt(minutes, 'minute')}`;
|
||||
} else if (hours > 0) {
|
||||
return `resets in ${fmt(hours, 'hour')}`;
|
||||
}
|
||||
|
||||
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}`;
|
||||
return `resets in ${fmt(minutes, 'minute')}`;
|
||||
};
|
||||
|
||||
@@ -20,13 +20,6 @@ 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.
|
||||
*
|
||||
@@ -95,9 +88,7 @@ export function calculateShellMaxLines(options: {
|
||||
|
||||
// 2. Handle cases where height is unknown (Standard mode history).
|
||||
if (availableTerminalHeight === undefined) {
|
||||
return isAlternateBuffer
|
||||
? ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD
|
||||
: undefined;
|
||||
return isAlternateBuffer ? ACTIVE_SHELL_MAX_LINES : undefined;
|
||||
}
|
||||
|
||||
const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2);
|
||||
@@ -112,8 +103,8 @@ export function calculateShellMaxLines(options: {
|
||||
// 4. Fall back to process-based constants.
|
||||
const isExecuting = status === CoreToolCallStatus.Executing;
|
||||
const shellMaxLinesLimit = isExecuting
|
||||
? ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD
|
||||
: COMPLETED_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD;
|
||||
? ACTIVE_SHELL_MAX_LINES
|
||||
: COMPLETED_SHELL_MAX_LINES;
|
||||
|
||||
return Math.min(maxLinesBasedOnHeight, shellMaxLinesLimit);
|
||||
}
|
||||
|
||||
@@ -494,10 +494,9 @@ export class ActivityLogger extends EventEmitter {
|
||||
|
||||
req.write = function (chunk: string | Uint8Array, ...etc: unknown[]) {
|
||||
if (chunk) {
|
||||
const arg0 = etc[0];
|
||||
const encoding =
|
||||
typeof arg0 === 'string' && Buffer.isEncoding(arg0)
|
||||
? arg0
|
||||
typeof etc[0] === 'string' && Buffer.isEncoding(etc[0])
|
||||
? etc[0]
|
||||
: undefined;
|
||||
requestChunks.push(
|
||||
Buffer.isBuffer(chunk)
|
||||
@@ -520,10 +519,9 @@ export class ActivityLogger extends EventEmitter {
|
||||
) {
|
||||
const chunk = typeof chunkOrCb === 'function' ? undefined : chunkOrCb;
|
||||
if (chunk) {
|
||||
const arg0 = etc[0];
|
||||
const encoding =
|
||||
typeof arg0 === 'string' && Buffer.isEncoding(arg0)
|
||||
? arg0
|
||||
typeof etc[0] === 'string' && Buffer.isEncoding(etc[0])
|
||||
? etc[0]
|
||||
: undefined;
|
||||
requestChunks.push(
|
||||
Buffer.isBuffer(chunk)
|
||||
|
||||
@@ -23,6 +23,8 @@ export enum AppEvent {
|
||||
PasteTimeout = 'paste-timeout',
|
||||
TerminalBackground = 'terminal-background',
|
||||
TransientMessage = 'transient-message',
|
||||
ExternalMessage = 'external-message',
|
||||
A2AListenerStarted = 'a2a-listener-started',
|
||||
}
|
||||
|
||||
export interface AppEvents {
|
||||
@@ -32,6 +34,8 @@ export interface AppEvents {
|
||||
[AppEvent.PasteTimeout]: never[];
|
||||
[AppEvent.TerminalBackground]: [string];
|
||||
[AppEvent.TransientMessage]: [TransientMessagePayload];
|
||||
[AppEvent.ExternalMessage]: [string];
|
||||
[AppEvent.A2AListenerStarted]: [number];
|
||||
}
|
||||
|
||||
export const appEvents = new EventEmitter<AppEvents>();
|
||||
|
||||
@@ -10,10 +10,16 @@ import {
|
||||
extractFirstUserMessage,
|
||||
formatRelativeTime,
|
||||
hasUserOrAssistantMessage,
|
||||
convertSessionToHistoryFormats,
|
||||
SessionError,
|
||||
} from './sessionUtils.js';
|
||||
import type { Config, MessageRecord } from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Config,
|
||||
MessageRecord,
|
||||
ConversationRecord,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { SESSION_FILE_PREFIX } from '@google/gemini-cli-core';
|
||||
import { MessageType } from '../ui/types.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
@@ -765,3 +771,80 @@ describe('formatRelativeTime', () => {
|
||||
expect(formatRelativeTime(thirtySecondsAgo.toISOString())).toBe('Just now');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertSessionToHistoryFormats', () => {
|
||||
const messages: ConversationRecord['messages'] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'user',
|
||||
timestamp: '2024-01-01T10:00:00Z',
|
||||
content: 'First question',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'gemini',
|
||||
timestamp: '2024-01-01T10:01:00Z',
|
||||
content: 'First answer',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'user',
|
||||
timestamp: '2024-01-01T10:02:00Z',
|
||||
content: 'Second question',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'gemini',
|
||||
timestamp: '2024-01-01T10:03:00Z',
|
||||
content: 'Second answer',
|
||||
},
|
||||
];
|
||||
|
||||
it('should convert all messages when startIndex is undefined', () => {
|
||||
const { uiHistory } = convertSessionToHistoryFormats(messages);
|
||||
|
||||
expect(uiHistory).toHaveLength(4);
|
||||
expect(uiHistory[0]).toEqual({
|
||||
type: MessageType.USER,
|
||||
text: 'First question',
|
||||
});
|
||||
expect(uiHistory[1]).toEqual({
|
||||
type: MessageType.GEMINI,
|
||||
text: 'First answer',
|
||||
});
|
||||
expect(uiHistory[2]).toEqual({
|
||||
type: MessageType.USER,
|
||||
text: 'Second question',
|
||||
});
|
||||
expect(uiHistory[3]).toEqual({
|
||||
type: MessageType.GEMINI,
|
||||
text: 'Second answer',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show only post-compression messages with a leading info message when startIndex is provided', () => {
|
||||
const { uiHistory } = convertSessionToHistoryFormats(messages, 2);
|
||||
|
||||
// Should have: 1 info message + 2 remaining messages
|
||||
expect(uiHistory).toHaveLength(3);
|
||||
|
||||
// First item is the compression info message
|
||||
expect(uiHistory[0].type).toBe(MessageType.INFO);
|
||||
expect((uiHistory[0] as { type: string; text: string }).text).toContain(
|
||||
'2 messages',
|
||||
);
|
||||
expect((uiHistory[0] as { type: string; text: string }).text).toContain(
|
||||
'compressed',
|
||||
);
|
||||
|
||||
// Remaining items are the post-compression messages
|
||||
expect(uiHistory[1]).toEqual({
|
||||
type: MessageType.USER,
|
||||
text: 'Second question',
|
||||
});
|
||||
expect(uiHistory[2]).toEqual({
|
||||
type: MessageType.GEMINI,
|
||||
text: 'Second answer',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -526,15 +526,31 @@ export class SessionSelector {
|
||||
|
||||
/**
|
||||
* Converts session/conversation data into UI history format.
|
||||
*
|
||||
* @param messages - The full array of recorded messages.
|
||||
* @param startIndex - If provided, only messages from this index onward are
|
||||
* converted. A leading info item is added to indicate earlier history was
|
||||
* compressed away.
|
||||
*/
|
||||
export function convertSessionToHistoryFormats(
|
||||
messages: ConversationRecord['messages'],
|
||||
startIndex?: number,
|
||||
): {
|
||||
uiHistory: HistoryItemWithoutId[];
|
||||
} {
|
||||
const uiHistory: HistoryItemWithoutId[] = [];
|
||||
const hasCompressedHistory =
|
||||
startIndex != null && startIndex > 0 && startIndex < messages.length;
|
||||
const slice = hasCompressedHistory ? messages.slice(startIndex) : messages;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (hasCompressedHistory) {
|
||||
uiHistory.push({
|
||||
type: MessageType.INFO,
|
||||
text: `ℹ️ Earlier history (${startIndex} messages) was compressed. Showing conversation from last compression point.`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const msg of slice) {
|
||||
// Add thoughts if present
|
||||
if (msg.type === 'gemini' && msg.thoughts && msg.thoughts.length > 0) {
|
||||
for (const thought of msg.thoughts) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user