Compare commits

..

4 Commits

Author SHA1 Message Date
Sandy Tao f27bcc1592 feat: disable interactive shell in Forever Mode
Interactive shell (PTY) is unnecessary in long-running Forever Mode
sessions and can cause hangs. Force-disable it when isForeverMode
is true.
2026-03-06 22:37:32 -08:00
Sandy Tao 651c1a28b8 perf: skip file I/O in updateMessagesFromHistory when no tool calls match
Build the partsMap before touching the conversation file. If no
function responses are found in the history, return immediately
without reading or writing the session JSON.
2026-03-06 22:03:45 -08:00
Sandy Tao e062f0d09a perf: skip pre-compression history on session resume
On resume (-r), the CLI was loading and replaying the entire session
recording, including messages that had already been compressed away.
For long-running Forever Mode sessions this made resume extremely slow.

Add lastCompressionIndex to ConversationRecord, stamped when
compression succeeds. On resume, only messages from that index
onward are loaded into the client history and UI. Fully backward
compatible — old sessions without the field load all messages as before.
2026-03-06 22:03:45 -08:00
Sandy Tao 79ea865790 feat: introduce Forever Mode with A2A listener
- Sisyphus: auto-resume timer with schedule_work tool
- Confucius: built-in sub-agent for knowledge consolidation before compression
- Hippocampus: in-memory short-term memory via background micro-consolidation
- Bicameral Voice: proactive knowledge alignment on user input
- Archive compression mode for long-running sessions
- Onboarding dialog for first-time Forever Mode setup
- Refresh system instruction per turn so hippocampus reaches the model
- Auto-start A2A HTTP server when Forever Mode + Sisyphus enabled
- Bridge external messages into session and capture responses
- Display A2A port in status bar alongside Sisyphus timer
2026-03-06 22:03:20 -08:00
150 changed files with 5168 additions and 2540 deletions
+1 -1
View File
@@ -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
-15
View File
@@ -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 |
+1 -1
View File
@@ -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.
-58
View File
@@ -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).
+1 -1
View File
@@ -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` |
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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.
+7 -2
View File
@@ -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
-5
View File
@@ -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
View File
@@ -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],
},
},
{
-26
View File
@@ -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);
},
});
});
-2
View File
@@ -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;
+4 -1
View File
@@ -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']);
+7
View File
@@ -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', () => {
+80 -4
View File
@@ -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,
+1
View File
@@ -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 });
+3
View File
@@ -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 {
+11 -1
View File
@@ -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,
+453
View File
@@ -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);
});
});
}
+1
View File
@@ -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,
+21
View File
@@ -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() {
+1
View File
@@ -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']
-1
View File
@@ -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'
);
}
+60 -62
View File
@@ -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!();
});
+78 -11
View File
@@ -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) =>
+8 -8
View File
@@ -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
);
};
+6 -33
View File
@@ -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();
});
+19 -44
View File
@@ -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} />,
);
+20 -24
View File
@@ -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();
+103 -213
View File
@@ -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%
"
`;
@@ -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">&gt;</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

@@ -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">&gt;</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);
@@ -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);
+3 -3
View File
@@ -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();
+185 -19
View File
@@ -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,
);
}
+14 -14
View File
@@ -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);
+1
View File
@@ -122,6 +122,7 @@ export interface CompressionProps {
originalTokenCount: number | null;
newTokenCount: number | null;
compressionStatus: CompressionStatus | null;
archivePath?: string;
}
/**
-19
View File
@@ -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');
+13 -45
View File
@@ -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')}`;
};
+3 -12
View File
@@ -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);
}
+4 -6
View File
@@ -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)
+4
View File
@@ -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>();
+84 -1
View File
@@ -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',
});
});
});
+17 -1
View File
@@ -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