Compare commits

..

9 Commits

Author SHA1 Message Date
Christian Gunderman a863a34a01 Fix hang at interactive prompt creating with vite. 2026-03-09 10:34:14 -07:00
Jacob Richman d012929a28 Code review comments as a pr (#21209) 2026-03-08 08:36:54 +00:00
Jenna Inouye 97dfbd4598 Docs: Add documentation for notifications (experimental)(macOS) (#21163) 2026-03-08 06:31:24 +00:00
Keith Guerin 237864eb63 feat(cli): Invert quota language to 'percent used' (#20100)
Co-authored-by: jacob314 <jacob314@gmail.com>
2026-03-07 23:17:10 +00:00
Keith Guerin dc6741097c refactor(cli): standardize on 'reload' verb for all components (#20654)
Co-authored-by: Krishna Korade <MushuEE@users.noreply.github.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-03-07 22:56:11 +00:00
Christian Gunderman dac3735626 Disallow underspecified types (#21485) 2026-03-07 21:05:38 +00:00
Jacob Richman 245b68e9f1 Make test suite pass when the GEMINI_SYSTEM_MD env variable or GEMINI_WRITE_SYSTEM_MD variable happens to be set locally/ (#21480) 2026-03-07 20:04:17 +00:00
Jacob Richman e89cf5d86e fix(cli): correct shell height reporting (#21492) 2026-03-07 19:31:09 +00:00
Jarrod Whelan 54b0344fc5 fix(ui): unify Ctrl+O expansion hint experience across buffer modes (#21474) 2026-03-07 19:04:22 +00:00
150 changed files with 2586 additions and 5214 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
npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present -- --coverage.enabled=false
npm run test:scripts
fi
+15
View File
@@ -24,6 +24,21 @@ and parameters.
| -------- | ----------------- | ------------------------------------------------------------------------------------------------------------------ |
| `query` | string (variadic) | Positional prompt. Defaults to one-shot mode. Use `-i/--prompt-interactive` to execute and continue interactively. |
## Interactive commands
These commands are available within the interactive REPL.
| Command | Description |
| -------------------- | ---------------------------------------- |
| `/skills reload` | Reload discovered skills from disk |
| `/agents reload` | Reload the agent registry |
| `/commands reload` | Reload custom slash commands |
| `/memory reload` | Reload context files (e.g., `GEMINI.md`) |
| `/mcp reload` | Restart and reload MCP servers |
| `/extensions reload` | Reload all active extensions |
| `/help` | Show help for all commands |
| `/quit` | Exit the interactive session |
## CLI Options
| Option | Alias | Type | Default | Description |
+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 refresh`**: Forces a re-scan and reload of all `GEMINI.md` files
- **`/memory reload`**: Forces a re-scan and reload of all `GEMINI.md` files
from all configured locations.
- **`/memory add <text>`**: Appends your text to your global
`~/.gemini/GEMINI.md` file. This lets you add persistent memories on the fly.
+58
View File
@@ -0,0 +1,58 @@
# 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 refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` |
| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory reload loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` |
| Respect .gitignore | `context.fileFiltering.respectGitIgnore` | Respect .gitignore files when searching. | `true` |
| Respect .geminiignore | `context.fileFiltering.respectGeminiIgnore` | Respect .geminiignore files when searching. | `true` |
| Enable Recursive File Search | `context.fileFiltering.enableRecursiveFileSearch` | Enable recursive file search functionality when completing @ references in the prompt. | `true` |
+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 refresh` to force the CLI to re-query the
server for its capabilities.
- **Tools not found?** Run `/mcp reload` to force the CLI to re-query the server
for its capabilities.
## Next steps
+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 refresh`
**Command:** `/memory reload`
## 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 refresh`: Reloads the agent registry. Use this after adding or
- `/agents reload`: Reloads the agent registry. Use this after adding or
modifying agent definition files.
- `/agents enable <agent_name>`: Enables a specific subagent.
- `/agents disable <agent_name>`: Disables a specific subagent.
+2 -7
View File
@@ -175,11 +175,6 @@ 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):
@@ -724,7 +719,7 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `[]`
- **`context.loadMemoryFromIncludeDirectories`** (boolean):
- **Description:** Controls how /memory refresh loads GEMINI.md files. When
- **Description:** Controls how /memory reload loads GEMINI.md files. When
true, include directories are scanned; when false, only the current
directory is used.
- **Default:** `false`
@@ -1710,7 +1705,7 @@ conventions and context.
loaded, allowing you to verify the hierarchy and content being used by the
AI.
- See the [Commands documentation](./commands.md#memory) for full details on
the `/memory` command and its sub-commands (`show` and `refresh`).
the `/memory` command and its sub-commands (`show` and `reload`).
By understanding and utilizing these configuration layers and the hierarchical
nature of context files, you can effectively manage the AI's memory and tailor
+5
View File
@@ -106,6 +106,11 @@
{ "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",
+11 -1
View File
@@ -132,7 +132,16 @@ export default tseslint.config(
'no-cond-assign': 'error',
'no-debugger': 'error',
'no-duplicate-case': 'error',
'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules],
'no-restricted-syntax': [
'error',
...commonRestrictedSyntaxRules,
{
selector:
'UnaryExpression[operator="typeof"] > MemberExpression[computed=true][property.type="Literal"]',
message:
'Do not use typeof to check object properties. Define a TypeScript interface and a type guard function instead.',
},
],
'no-unsafe-finally': 'error',
'no-unused-expressions': 'off', // Disable base rule
'@typescript-eslint/no-unused-expressions': [
@@ -263,6 +272,7 @@ export default tseslint.config(
...vitest.configs.recommended.rules,
'vitest/expect-expect': 'off',
'vitest/no-commented-out-tests': 'off',
'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules],
},
},
{
+26
View File
@@ -71,4 +71,30 @@ 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,7 +832,9 @@ export class Task {
if (
part.kind !== 'data' ||
!part.data ||
// eslint-disable-next-line no-restricted-syntax
typeof part.data['callId'] !== 'string' ||
// eslint-disable-next-line no-restricted-syntax
typeof part.data['outcome'] !== 'string'
) {
return false;
+1 -4
View File
@@ -358,10 +358,7 @@ export class GeminiAgent {
config.setFileSystemService(acpFileSystemService);
}
const clientHistory = convertSessionToClientHistory(
sessionData.messages,
sessionData.lastCompressionIndex,
);
const clientHistory = convertSessionToClientHistory(sessionData.messages);
const geminiClient = config.getGeminiClient();
await geminiClient.initialize();
@@ -79,6 +79,7 @@ function migrateClaudeHook(claudeHook: unknown): unknown {
migrated['command'] = hook['command'];
// Replace CLAUDE_PROJECT_DIR with GEMINI_PROJECT_DIR in command
// eslint-disable-next-line no-restricted-syntax
if (typeof migrated['command'] === 'string') {
migrated['command'] = migrated['command'].replace(
/\$CLAUDE_PROJECT_DIR/g,
@@ -93,6 +94,7 @@ function migrateClaudeHook(claudeHook: unknown): unknown {
}
// Map timeout field (Claude uses seconds, Gemini uses seconds)
// eslint-disable-next-line no-restricted-syntax
if ('timeout' in hook && typeof hook['timeout'] === 'number') {
migrated['timeout'] = hook['timeout'];
}
@@ -140,6 +142,7 @@ function migrateClaudeHooks(claudeConfig: unknown): Record<string, unknown> {
// Transform matcher
if (
'matcher' in definition &&
// eslint-disable-next-line no-restricted-syntax
typeof definition['matcher'] === 'string'
) {
migratedDef['matcher'] = transformMatcher(definition['matcher']);
-7
View File
@@ -671,13 +671,6 @@ 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', () => {
+4 -80
View File
@@ -5,7 +5,6 @@
*/
import yargs from 'yargs/yargs';
import { hideBin } from 'yargs/helpers';
import process from 'node:process';
import { mcpCommand } from '../commands/mcp.js';
@@ -39,7 +38,6 @@ import {
type HookDefinition,
type HookEventName,
type OutputFormat,
type SisyphusModeSettings,
} from '@google/gemini-cli-core';
import {
type Settings,
@@ -69,7 +67,6 @@ 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;
@@ -146,12 +143,7 @@ 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',
@@ -496,68 +488,6 @@ 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[] = [];
@@ -581,11 +511,8 @@ export async function loadCliConfig(
filePaths = result.filePaths;
}
const question =
argv.promptInteractive ||
argv.prompt ||
process.env['GEMINI_CLI_INITIAL_PROMPT'] ||
'';
const question = argv.promptInteractive || argv.prompt || '';
// Determine approval mode with backward compatibility
let approvalMode: ApprovalMode;
const rawApprovalMode =
@@ -678,8 +605,7 @@ export async function loadCliConfig(
!!argv.acp ||
!!argv.experimentalAcp ||
(!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) &&
!argv.isCommand) ||
!!argv.forever;
!argv.isCommand);
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
@@ -847,8 +773,6 @@ 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,7 +183,6 @@ describe('resolveWorkspacePolicyState', () => {
setAutoAcceptWorkspacePolicies(originalValue);
}
});
it('should not return workspace policies if cwd is the home directory', async () => {
const policiesDir = path.join(tempDir, '.gemini', 'policies');
fs.mkdirSync(policiesDir, { recursive: true });
-3
View File
@@ -185,9 +185,6 @@ 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 {
+1 -11
View File
@@ -376,16 +376,6 @@ 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.',
},
@@ -1179,7 +1169,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: false,
default: false,
description: oneLine`
Controls how /memory refresh loads GEMINI.md files.
Controls how /memory reload loads GEMINI.md files.
When true, include directories are scanned; when false, only the current directory is used.
`,
showInDialog: true,
-453
View File
@@ -1,453 +0,0 @@
/**
* @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,7 +479,6 @@ 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,7 +84,6 @@ 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';
@@ -324,26 +323,6 @@ 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,7 +222,6 @@ export async function runNonInteractive({
await geminiClient.resumeChat(
convertSessionToClientHistory(
resumedSessionData.conversation.messages,
resumedSessionData.conversation.lastCompressionIndex,
),
resumedSessionData,
);
@@ -65,6 +65,7 @@ export function mockCoreDebugLogger<T extends Record<string, unknown>>(
return {
...actual,
coreEvents: {
// eslint-disable-next-line no-restricted-syntax
...(typeof actual['coreEvents'] === 'object' &&
actual['coreEvents'] !== null
? actual['coreEvents']
+1
View File
@@ -96,6 +96,7 @@ function isInkRenderMetrics(
typeof m === 'object' &&
m !== null &&
'output' in m &&
// eslint-disable-next-line no-restricted-syntax
typeof m['output'] === 'string'
);
}
+62 -60
View File
@@ -232,10 +232,7 @@ import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import * as useKeypressModule from './hooks/useKeypress.js';
import { useSuspend } from './hooks/useSuspend.js';
import { measureElement } from 'ink';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import {
ShellExecutionService,
writeToStdout,
enableMouseEvents,
disableMouseEvents,
@@ -335,7 +332,6 @@ describe('AppContainer State Management', () => {
backgroundShells: new Map(),
registerBackgroundShell: vi.fn(),
dismissBackgroundShell: vi.fn(),
sisyphusSecondsRemaining: null,
};
beforeEach(() => {
@@ -2198,35 +2194,6 @@ 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;
@@ -3142,30 +3109,6 @@ 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();
@@ -3466,6 +3409,63 @@ 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 NOT set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => {
it('DOES set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => {
const alternateSettings = mergeSettings({}, {}, {}, {}, true);
const settingsWithAlternateBuffer = {
merged: {
@@ -3635,8 +3635,10 @@ describe('AppContainer State Management', () => {
capturedOverflowActions.addOverflowingId('test-id');
});
// Should NOT show hint because we are in Alternate Buffer Mode
expect(capturedUIState.showIsExpandableHint).toBe(false);
// Should NOW show hint because we are in Alternate Buffer Mode
await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(true);
});
unmount!();
});
+11 -78
View File
@@ -126,7 +126,6 @@ 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';
@@ -232,19 +231,6 @@ 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<
@@ -297,19 +283,18 @@ export const AppContainer = (props: AppContainerProps) => {
* Manages the visibility and x-second timer for the expansion hint.
*
* This effect triggers the timer countdown whenever an overflow is detected
* or the user manually toggles the expansion state with Ctrl+O. We use a stable
* boolean dependency (hasOverflowState) to ensure the timer only resets on
* genuine state transitions, preventing it from infinitely resetting during
* active text streaming.
* or the user manually toggles the expansion state with Ctrl+O.
* By depending on overflowingIdsSize, the timer resets when *new* views
* overflow, but avoids infinitely resetting during single-view streaming.
*
* In alternate buffer mode, we don't trigger the hint automatically on overflow
* to avoid noise, but the user can still trigger it manually with Ctrl+O.
*/
useEffect(() => {
if (hasOverflowState && !isAlternateBuffer) {
if (hasOverflowState) {
triggerExpandHint(true);
}
}, [hasOverflowState, isAlternateBuffer, triggerExpandHint]);
}, [hasOverflowState, overflowingIdsSize, triggerExpandHint]);
const [defaultBannerText, setDefaultBannerText] = useState('');
const [warningBannerText, setWarningBannerText] = useState('');
@@ -1024,10 +1009,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
historyManager.addItem(
{
type: MessageType.INFO,
text: `Memory refreshed successfully. ${
text: `Memory reloaded successfully. ${
flattenedMemory.length > 0
? `Loaded ${flattenedMemory.length} characters from ${fileCount} file(s).`
: 'No memory content found.'
? `Loaded ${flattenedMemory.length} characters from ${fileCount} file(s)`
: 'No memory content found'
}`,
},
Date.now(),
@@ -1127,7 +1112,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
backgroundShells,
dismissBackgroundShell,
retryStatus,
sisyphusSecondsRemaining,
} = useGeminiStream(
config.getGeminiClient(),
historyManager.history,
@@ -1219,53 +1203,6 @@ 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 = [
@@ -1493,8 +1430,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
!isThemeDialogOpen &&
!isEditorDialogOpen &&
!showPrivacyNotice &&
geminiClient?.isInitialized?.() &&
isMcpReady
geminiClient?.isInitialized?.()
) {
void handleFinalSubmit(initialPrompt);
initialPromptSubmitted.current = true;
@@ -1509,7 +1445,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
isEditorDialogOpen,
showPrivacyNotice,
geminiClient,
isMcpReady,
]);
const [idePromptAnswered, setIdePromptAnswered] = useState(false);
@@ -2032,6 +1967,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const nightly = props.version.includes('nightly');
const dialogsVisible =
shouldShowIdePrompt ||
shouldShowIdePrompt ||
isFolderTrustDialogOpen ||
isPolicyUpdateDialogOpen ||
@@ -2336,11 +2272,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
...pendingGeminiHistoryItems,
]),
hintBuffer: '',
sisyphusSecondsRemaining,
a2aListenerPort,
}),
[
isThemeDialogOpen,
themeError,
isAuthenticating,
isConfigInitialized,
@@ -2458,8 +2393,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
adminSettingsChanged,
newAgents,
showIsExpandableHint,
sisyphusSecondsRemaining,
a2aListenerPort,
],
);
@@ -105,34 +105,40 @@ describe('agentsCommand', () => {
);
});
it('should reload the agent registry when refresh subcommand is called', async () => {
it('should reload the agent registry when reload subcommand is called', async () => {
const reloadSpy = vi.fn().mockResolvedValue(undefined);
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
reload: reloadSpy,
});
const refreshCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'refresh',
const reloadCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'reload',
);
expect(refreshCommand).toBeDefined();
expect(reloadCommand).toBeDefined();
const result = await refreshCommand!.action!(mockContext, '');
const result = await reloadCommand!.action!(mockContext, '');
expect(reloadSpy).toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Reloading agent registry...',
}),
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Agents refreshed successfully.',
content: 'Agents reloaded successfully',
});
});
it('should show an error if agent registry is not available during refresh', async () => {
it('should show an error if agent registry is not available during reload', async () => {
mockConfig.getAgentRegistry = vi.fn().mockReturnValue(undefined);
const refreshCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'refresh',
const reloadCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'reload',
);
const result = await refreshCommand!.action!(mockContext, '');
const result = await reloadCommand!.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
@@ -322,9 +322,9 @@ const configCommand: SlashCommand = {
completion: completeAllAgents,
};
const agentsRefreshCommand: SlashCommand = {
name: 'refresh',
altNames: ['reload'],
const agentsReloadCommand: SlashCommand = {
name: 'reload',
altNames: ['refresh'],
description: 'Reload the agent registry',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
@@ -340,7 +340,7 @@ const agentsRefreshCommand: SlashCommand = {
context.ui.addItem({
type: MessageType.INFO,
text: 'Refreshing agent registry...',
text: 'Reloading agent registry...',
});
await agentRegistry.reload();
@@ -348,7 +348,7 @@ const agentsRefreshCommand: SlashCommand = {
return {
type: 'message',
messageType: 'info',
content: 'Agents refreshed successfully.',
content: 'Agents reloaded successfully',
};
},
};
@@ -359,7 +359,7 @@ export const agentsCommand: SlashCommand = {
kind: CommandKind.BUILT_IN,
subCommands: [
agentsListCommand,
agentsRefreshCommand,
agentsReloadCommand,
enableCommand,
disableCommand,
configCommand,
@@ -53,7 +53,6 @@ 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('restart', () => {
describe('reload', () => {
let restartAction: SlashCommand['action'];
let mockRestartExtension: MockedFunction<
typeof ExtensionLoader.prototype.restartExtension
@@ -900,7 +900,7 @@ describe('extensionsCommand', () => {
beforeEach(() => {
restartAction = extensionsCommand().subCommands?.find(
(c) => c.name === 'restart',
(c) => c.name === 'reload',
)?.action;
expect(restartAction).not.toBeNull();
@@ -911,7 +911,7 @@ describe('extensionsCommand', () => {
getExtensions: mockGetExtensions,
restartExtension: mockRestartExtension,
}));
mockContext.invocation!.name = 'restart';
mockContext.invocation!.name = 'reload';
});
it('should show a message if no extensions are installed', async () => {
@@ -930,7 +930,7 @@ describe('extensionsCommand', () => {
});
});
it('restarts all active extensions when --all is provided', async () => {
it('reloads all active extensions when --all is provided', async () => {
const mockExtensions = [
{ name: 'ext1', isActive: true },
{ name: 'ext2', isActive: true },
@@ -946,13 +946,13 @@ describe('extensionsCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Restarting 2 extensions...',
text: 'Reloading 2 extensions...',
}),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: '2 extensions restarted successfully.',
text: '2 extensions reloaded successfully',
}),
);
expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({
@@ -986,7 +986,7 @@ describe('extensionsCommand', () => {
);
});
it('restarts only specified active extensions', async () => {
it('reloads only specified active extensions', async () => {
const mockExtensions = [
{ name: 'ext1', isActive: false },
{ name: 'ext2', isActive: true },
@@ -1024,13 +1024,13 @@ describe('extensionsCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Usage: /extensions restart <extension-names>|--all',
text: 'Usage: /extensions reload <extension-names>|--all',
}),
);
expect(mockRestartExtension).not.toHaveBeenCalled();
});
it('handles errors during extension restart', async () => {
it('handles errors during extension reload', async () => {
const mockExtensions = [
{ name: 'ext1', isActive: true },
] as GeminiCLIExtension[];
@@ -1043,7 +1043,7 @@ describe('extensionsCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Failed to restart some extensions:\n ext1: Failed to restart',
text: 'Failed to reload some extensions:\n ext1: Failed to restart',
}),
);
});
@@ -1066,7 +1066,7 @@ describe('extensionsCommand', () => {
);
});
it('does not restart any extensions if none are found', async () => {
it('does not reload any extensions if none are found', async () => {
const mockExtensions = [
{ name: 'ext1', isActive: true },
] as GeminiCLIExtension[];
@@ -1083,8 +1083,8 @@ describe('extensionsCommand', () => {
);
});
it('should suggest only enabled extension names for the restart command', async () => {
mockContext.invocation!.name = 'restart';
it('should suggest only enabled extension names for the reload command', async () => {
mockContext.invocation!.name = 'reload';
const mockExtensions = [
{ name: 'ext1', isActive: true },
{ name: 'ext2', isActive: false },
@@ -176,7 +176,7 @@ async function restartAction(
if (!all && names?.length === 0) {
context.ui.addItem({
type: MessageType.ERROR,
text: 'Usage: /extensions restart <extension-names>|--all',
text: 'Usage: /extensions reload <extension-names>|--all',
});
return Promise.resolve();
}
@@ -208,12 +208,12 @@ async function restartAction(
const s = extensionsToRestart.length > 1 ? 's' : '';
const restartingMessage = {
const reloadingMessage = {
type: MessageType.INFO,
text: `Restarting ${extensionsToRestart.length} extension${s}...`,
text: `Reloading ${extensionsToRestart.length} extension${s}...`,
color: theme.text.primary,
};
context.ui.addItem(restartingMessage);
context.ui.addItem(reloadingMessage);
const results = await Promise.allSettled(
extensionsToRestart.map(async (extension) => {
@@ -254,12 +254,12 @@ async function restartAction(
.join('\n ');
context.ui.addItem({
type: MessageType.ERROR,
text: `Failed to restart some extensions:\n ${errorMessages}`,
text: `Failed to reload some extensions:\n ${errorMessages}`,
});
} else {
const infoItem: HistoryItemInfo = {
type: MessageType.INFO,
text: `${extensionsToRestart.length} extension${s} restarted successfully.`,
text: `${extensionsToRestart.length} extension${s} reloaded successfully`,
icon: emptyIcon,
color: theme.text.primary,
};
@@ -729,7 +729,8 @@ export function completeExtensions(
}
if (
context.invocation?.name === 'disable' ||
context.invocation?.name === 'restart'
context.invocation?.name === 'restart' ||
context.invocation?.name === 'reload'
) {
extensions = extensions.filter((ext) => ext.isActive);
}
@@ -824,9 +825,10 @@ const exploreExtensionsCommand: SlashCommand = {
action: exploreAction,
};
const restartCommand: SlashCommand = {
name: 'restart',
description: 'Restart all extensions',
const reloadCommand: SlashCommand = {
name: 'reload',
altNames: ['restart'],
description: 'Reload all extensions',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: restartAction,
@@ -863,7 +865,7 @@ export function extensionsCommand(
listExtensionsCommand,
updateExtensionsCommand,
exploreExtensionsCommand,
restartCommand,
reloadCommand,
...conditionalCommands,
],
action: (context, args) =>
+8 -8
View File
@@ -149,7 +149,7 @@ const authCommand: SlashCommand = {
return {
type: 'message',
messageType: 'info',
content: `Successfully authenticated and refreshed tools for '${serverName}'.`,
content: `Successfully authenticated and reloaded tools for '${serverName}'`,
};
} catch (error) {
return {
@@ -325,10 +325,10 @@ const schemaCommand: SlashCommand = {
action: (context) => listAction(context, true, true),
};
const refreshCommand: SlashCommand = {
name: 'refresh',
altNames: ['reload'],
description: 'Restarts MCP servers',
const reloadCommand: SlashCommand = {
name: 'reload',
altNames: ['refresh'],
description: 'Reloads MCP servers',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (
@@ -354,7 +354,7 @@ const refreshCommand: SlashCommand = {
context.ui.addItem({
type: 'info',
text: 'Restarting MCP servers...',
text: 'Reloading MCP servers...',
});
await mcpClientManager.restart();
@@ -460,7 +460,7 @@ async function handleEnableDisable(
const mcpClientManager = config.getMcpClientManager();
if (mcpClientManager) {
context.ui.addItem(
{ type: 'info', text: 'Restarting MCP servers...' },
{ type: 'info', text: 'Reloading MCP servers...' },
Date.now(),
);
await mcpClientManager.restart();
@@ -521,7 +521,7 @@ export const mcpCommand: SlashCommand = {
descCommand,
schemaCommand,
authCommand,
refreshCommand,
reloadCommand,
enableCommand,
disableCommand,
],
@@ -39,13 +39,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
return {
type: 'message',
messageType: 'info',
content: `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`,
content: `Memory reloaded successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`,
};
}
return {
type: 'message',
messageType: 'info',
content: 'Memory refreshed successfully.',
content: 'Memory reloaded successfully.',
};
}),
showMemory: vi.fn(),
@@ -63,7 +63,7 @@ describe('memoryCommand', () => {
let mockContext: CommandContext;
const getSubCommand = (
name: 'show' | 'add' | 'refresh' | 'list',
name: 'show' | 'add' | 'reload' | 'list',
): SlashCommand => {
const subCommand = memoryCommand.subCommands?.find(
(cmd) => cmd.name === name,
@@ -206,15 +206,15 @@ describe('memoryCommand', () => {
});
});
describe('/memory refresh', () => {
let refreshCommand: SlashCommand;
describe('/memory reload', () => {
let reloadCommand: SlashCommand;
let mockSetUserMemory: Mock;
let mockSetGeminiMdFileCount: Mock;
let mockSetGeminiMdFilePaths: Mock;
let mockContextManagerRefresh: Mock;
beforeEach(() => {
refreshCommand = getSubCommand('refresh');
reloadCommand = getSubCommand('reload');
mockSetUserMemory = vi.fn();
mockSetGeminiMdFileCount = vi.fn();
mockSetGeminiMdFilePaths = vi.fn();
@@ -266,7 +266,7 @@ describe('memoryCommand', () => {
});
it('should use ContextManager.refresh when JIT is enabled', async () => {
if (!refreshCommand.action) throw new Error('Command has no action');
if (!reloadCommand.action) throw new Error('Command has no action');
// Enable JIT in mock config
const config = mockContext.services.config;
@@ -276,7 +276,7 @@ describe('memoryCommand', () => {
vi.mocked(config.getUserMemory).mockReturnValue('JIT Memory Content');
vi.mocked(config.getGeminiMdFileCount).mockReturnValue(3);
await refreshCommand.action(mockContext, '');
await reloadCommand.action(mockContext, '');
expect(mockContextManagerRefresh).toHaveBeenCalledOnce();
expect(mockRefreshServerHierarchicalMemory).not.toHaveBeenCalled();
@@ -284,29 +284,29 @@ describe('memoryCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Memory refreshed successfully. Loaded 18 characters from 3 file(s).',
text: 'Memory reloaded successfully. Loaded 18 characters from 3 file(s).',
},
expect.any(Number),
);
});
it('should display success message when memory is refreshed with content (Legacy)', async () => {
if (!refreshCommand.action) throw new Error('Command has no action');
it('should display success message when memory is reloaded with content (Legacy)', async () => {
if (!reloadCommand.action) throw new Error('Command has no action');
const successMessage = {
type: 'message',
messageType: MessageType.INFO,
content:
'Memory refreshed successfully. Loaded 18 characters from 2 file(s).',
'Memory reloaded successfully. Loaded 18 characters from 2 file(s).',
};
mockRefreshMemory.mockResolvedValue(successMessage);
await refreshCommand.action(mockContext, '');
await reloadCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Refreshing memory from source files...',
text: 'Reloading memory from source files...',
},
expect.any(Number),
);
@@ -316,42 +316,42 @@ describe('memoryCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Memory refreshed successfully. Loaded 18 characters from 2 file(s).',
text: 'Memory reloaded successfully. Loaded 18 characters from 2 file(s).',
},
expect.any(Number),
);
});
it('should display success message when memory is refreshed with no content', async () => {
if (!refreshCommand.action) throw new Error('Command has no action');
it('should display success message when memory is reloaded with no content', async () => {
if (!reloadCommand.action) throw new Error('Command has no action');
const successMessage = {
type: 'message',
messageType: MessageType.INFO,
content: 'Memory refreshed successfully. No memory content found.',
content: 'Memory reloaded successfully. No memory content found.',
};
mockRefreshMemory.mockResolvedValue(successMessage);
await refreshCommand.action(mockContext, '');
await reloadCommand.action(mockContext, '');
expect(mockRefreshMemory).toHaveBeenCalledOnce();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Memory refreshed successfully. No memory content found.',
text: 'Memory reloaded successfully. No memory content found.',
},
expect.any(Number),
);
});
it('should display an error message if refreshing fails', async () => {
if (!refreshCommand.action) throw new Error('Command has no action');
it('should display an error message if reloading fails', async () => {
if (!reloadCommand.action) throw new Error('Command has no action');
const error = new Error('Failed to read memory files.');
mockRefreshMemory.mockRejectedValue(error);
await refreshCommand.action(mockContext, '');
await reloadCommand.action(mockContext, '');
expect(mockRefreshMemory).toHaveBeenCalledOnce();
expect(mockSetUserMemory).not.toHaveBeenCalled();
@@ -361,27 +361,27 @@ describe('memoryCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: `Error refreshing memory: ${error.message}`,
text: `Error reloading memory: ${error.message}`,
},
expect.any(Number),
);
});
it('should not throw if config service is unavailable', async () => {
if (!refreshCommand.action) throw new Error('Command has no action');
if (!reloadCommand.action) throw new Error('Command has no action');
const nullConfigContext = createMockCommandContext({
services: { config: null },
});
await expect(
refreshCommand.action(nullConfigContext, ''),
reloadCommand.action(nullConfigContext, ''),
).resolves.toBeUndefined();
expect(nullConfigContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Refreshing memory from source files...',
text: 'Reloading memory from source files...',
},
expect.any(Number),
);
@@ -63,16 +63,16 @@ export const memoryCommand: SlashCommand = {
},
},
{
name: 'refresh',
altNames: ['reload'],
description: 'Refresh the memory from the source',
name: 'reload',
altNames: ['refresh'],
description: 'Reload the memory from the source',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'Refreshing memory from source files...',
text: 'Reloading memory from source files...',
},
Date.now(),
);
@@ -95,7 +95,7 @@ export const memoryCommand: SlashCommand = {
{
type: MessageType.ERROR,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
text: `Error refreshing memory: ${(error as Error).message}`,
text: `Error reloading memory: ${(error as Error).message}`,
},
Date.now(),
);
@@ -20,6 +20,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
UserAccountManager: vi.fn().mockImplementation(() => ({
getCachedGoogleAccount: vi.fn().mockReturnValue('mock@example.com'),
})),
getG1CreditBalance: vi.fn().mockReturnValue(undefined),
};
});
@@ -208,8 +208,6 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
proQuotaRequest: null,
validationRequest: null,
},
sisyphusSecondsRemaining: null,
a2aListenerPort: null,
...overrides,
}) as UIState;
@@ -311,9 +311,5 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
</Box>
);
return isAlternateBuffer ? (
<OverflowProvider>{content}</OverflowProvider>
) : (
content
);
return <OverflowProvider>{content}</OverflowProvider>;
};
+33 -6
View File
@@ -132,9 +132,7 @@ describe('<Footer />', () => {
const output = lastFrame();
expect(output).toBeDefined();
// Should contain some part of the path, likely shortened
expect(output).toContain(
path.join('directories', 'to', 'make', 'it', 'long'),
);
expect(output).toContain(path.join('make', 'it'));
unmount();
});
@@ -149,9 +147,38 @@ describe('<Footer />', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
expect(output).toContain(
path.join('directories', 'to', 'make', 'it', 'long'),
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'],
},
},
}),
},
);
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();
});
});
@@ -235,7 +262,7 @@ describe('<Footer />', () => {
},
);
await waitUntilReady();
expect(lastFrame()).toContain('15%');
expect(lastFrame()).toContain('85%');
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
+44 -19
View File
@@ -103,6 +103,9 @@ export interface FooterRowItem {
key: string;
header: string;
element: React.ReactNode;
flexGrow?: number;
flexShrink?: number;
isFocused?: boolean;
}
const COLUMN_GAP = 3;
@@ -123,10 +126,20 @@ export const FooterRow: React.FC<{
}
elements.push(
<Box key={item.key} flexDirection="column">
<Box
key={item.key}
flexDirection="column"
flexGrow={item.flexGrow ?? 0}
flexShrink={item.flexShrink ?? 1}
backgroundColor={item.isFocused ? theme.background.focus : undefined}
>
{showLabels && (
<Box height={1}>
<Text color={theme.ui.comment}>{item.header}</Text>
<Text
color={item.isFocused ? theme.text.primary : theme.ui.comment}
>
{item.header}
</Text>
</Box>
)}
<Box height={1}>{item.element}</Box>
@@ -138,6 +151,7 @@ export const FooterRow: React.FC<{
<Box
flexDirection="row"
flexWrap="nowrap"
width="100%"
columnGap={showLabels ? COLUMN_GAP : 0}
>
{elements}
@@ -408,41 +422,50 @@ 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 (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
for (const col of potentialColumns) {
const gap = columnsToRender.length > 0 ? (showLabels ? COLUMN_GAP : 3) : 0;
const budgetWidth = col.id === 'workspace' ? 20 : col.width;
if (
col.isHighPriority ||
currentWidth + gap + budgetWidth <= terminalWidth - 2
currentUsedWidth + gap + budgetWidth <= terminalWidth - 2
) {
columnsToRender.push(col);
currentWidth += gap + budgetWidth;
currentUsedWidth += 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 maxWidth = col.id === 'workspace' ? 20 + excessSpace : col.width;
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;
return {
key: col.id,
header: col.header,
element: col.element(maxWidth),
element: col.element(estimatedWidth),
flexGrow: isWorkspace ? 1 : 0,
flexShrink: isWorkspace ? 1 : 0,
};
});
@@ -451,6 +474,8 @@ export const Footer: React.FC = () => {
key: 'ellipsis',
header: '',
element: <Text color={theme.ui.comment}></Text>,
flexGrow: 0,
flexShrink: 0,
});
}
@@ -24,13 +24,14 @@ describe('<FooterConfigDialog />', () => {
it('renders correctly with default settings', async () => {
const settings = createMockSettings();
const { lastFrame, waitUntilReady } = renderWithProviders(
const renderResult = renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
await renderResult.waitUntilReady();
expect(renderResult.lastFrame()).toMatchSnapshot();
await expect(renderResult).toMatchSvgSnapshot();
});
it('toggles an item when enter is pressed', async () => {
@@ -66,7 +67,7 @@ describe('<FooterConfigDialog />', () => {
);
await waitUntilReady();
// Initial order: workspace, branch, ...
// Initial order: workspace, git-branch, ...
const output = lastFrame();
const cwdIdx = output.indexOf('] workspace');
const branchIdx = output.indexOf('] git-branch');
@@ -108,22 +109,40 @@ describe('<FooterConfigDialog />', () => {
it('highlights the active item in the preview', async () => {
const settings = createMockSettings();
const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
const renderResult = renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
const { lastFrame, stdin, waitUntilReady } = renderResult;
await waitUntilReady();
expect(lastFrame()).toContain('~/project/path');
// Move focus down to 'git-branch'
// 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
act(() => {
stdin.write('\u001b[B'); // Down arrow
stdin.write('\r');
});
await waitFor(() => {
expect(lastFrame()).toContain('main');
// It should now be checked and appear in the preview
expect(lastFrame()).toMatch(/> \[✓\] code-changes/);
expect(lastFrame()).toContain('+12 -4');
});
await expect(renderResult).toMatchSvgSnapshot();
});
it('shows an empty preview when all items are deselected', async () => {
@@ -134,20 +153,64 @@ describe('<FooterConfigDialog />', () => {
);
await waitUntilReady();
for (let i = 0; i < 10; i++) {
// Default items are the first 5. We toggle them off.
for (let i = 0; i < 5; i++) {
act(() => {
stdin.write('\r'); // Toggle off
});
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('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');
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);
});
});
});
@@ -5,119 +5,75 @@
*/
import type React from 'react';
import { useCallback, useMemo, useReducer } from 'react';
import { useCallback, useMemo, useReducer, useState } 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_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' };
| { type: 'MOVE_ITEM'; id: string; direction: number }
| { type: 'TOGGLE_ITEM'; id: string }
| { type: 'SET_STATE'; payload: Partial<FooterConfigState> };
function footerConfigReducer(
state: FooterConfigState,
action: FooterConfigAction,
): FooterConfigState {
switch (action.type) {
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
case 'MOVE_ITEM': {
const currentIndex = state.orderedIds.indexOf(action.id);
const newIndex = currentIndex + action.direction;
if (
currentIndex === -1 ||
newIndex < 0 ||
newIndex >= state.orderedIds.length
) {
newOffset = newIndex - maxToShow + 1;
return state;
}
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, activeIndex: newIndex };
return { ...state, orderedIds: newOrderedIds };
}
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(item.key)) {
nextSelected.delete(item.key);
if (nextSelected.has(action.id)) {
nextSelected.delete(action.id);
} else {
nextSelected.add(item.key);
nextSelected.add(action.id);
}
return { ...state, selectedIds: nextSelected };
}
case 'SET_STATE':
return { ...state, ...action.payload };
case 'RESET_INDEX':
return { ...state, activeIndex: 0, scrollOffset: 0 };
default:
return state;
}
@@ -127,40 +83,54 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
onClose,
}) => {
const { settings, setSetting } = useSettingsStore();
const maxItemsToShow = 10;
const { constrainHeight, terminalHeight, staticExtraHeight } = useUIState();
const [state, dispatch] = useReducer(footerConfigReducer, undefined, () =>
resolveFooterState(settings.merged),
);
const [state, dispatch] = useReducer(footerConfigReducer, undefined, () => ({
...resolveFooterState(settings.merged),
activeIndex: 0,
scrollOffset: 0,
}));
const { orderedIds, selectedIds } = state;
const [focusKey, setFocusKey] = useState<string | undefined>(orderedIds[0]);
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 {
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: {
key: id,
id,
label: item.id,
description: item.description as string,
};
})
.filter((i): i is NonNullable<typeof i> => i !== null),
[orderedIds],
);
type: 'config' as const,
},
};
})
.filter((i): i is NonNullable<typeof i> => i !== null);
const maxLabelWidth = useMemo(
() => listItems.reduce((max, item) => Math.max(max, item.label.length), 0),
[listItems],
);
items.push({
key: 'show-labels',
value: {
key: 'show-labels',
id: 'show-labels',
label: 'Show footer labels',
type: 'labels-toggle',
},
});
const isResetFocused = activeIndex === listItems.length + 1;
const isShowLabelsFocused = activeIndex === listItems.length;
items.push({
key: 'reset',
value: {
key: 'reset',
id: 'reset',
label: 'Reset to default footer',
type: 'reset',
},
});
return items;
}, [orderedIds]);
const handleSaveAndClose = useCallback(() => {
const finalItems = orderedIds.filter((id: string) => selectedIds.has(id));
@@ -179,14 +149,9 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
const handleResetToDefaults = useCallback(() => {
setSetting(SettingScope.User, 'ui.footer.items', undefined);
dispatch({
type: 'SET_STATE',
payload: {
...resolveFooterState(settings.merged),
activeIndex: 0,
scrollOffset: 0,
},
});
const newState = resolveFooterState(settings.merged);
dispatch({ type: 'SET_STATE', payload: newState });
setFocusKey(newState.orderedIds[0]);
}, [setSetting, settings.merged]);
const handleToggleLabels = useCallback(() => {
@@ -194,6 +159,23 @@ 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)) {
@@ -201,43 +183,18 @@ 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)) {
dispatch({ type: 'MOVE_LEFT', items: listItems });
return true;
if (focusKey && orderedIds.includes(focusKey)) {
dispatch({ type: 'MOVE_ITEM', id: focusKey, direction: -1 });
return true;
}
}
if (keyMatchers[Command.MOVE_RIGHT](key)) {
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 });
if (focusKey && orderedIds.includes(focusKey)) {
dispatch({ type: 'MOVE_ITEM', id: focusKey, direction: 1 });
return true;
}
return true;
}
return false;
@@ -245,17 +202,11 @@ 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 (isResetFocused) {
if (focusKey === 'reset') {
return (
<Text color={theme.ui.comment} italic>
Default footer (uses legacy settings)
@@ -269,8 +220,9 @@ 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) =>
id === activeId ? 'white' : defaultColor || itemColor;
defaultColor || itemColor;
// Mock data for preview (headers come from ALL_ITEMS)
const mockData: Record<string, React.ReactNode> = {
@@ -312,16 +264,43 @@ 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">
<Box flexShrink={0}>
<FooterRow items={rowItems} showLabels={showLabels} />
</Box>
<Box overflow="hidden" flexWrap="nowrap" width="100%">
<FooterRow items={rowItems} showLabels={showLabels} />
</Box>
);
}, [orderedIds, selectedIds, activeId, isResetFocused, showLabels]);
}, [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)),
);
return (
<Box
@@ -329,7 +308,7 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
borderStyle="round"
borderColor={theme.border.default}
paddingX={2}
paddingY={1}
paddingY={includePadding ? 1 : 0}
width="100%"
>
<Text bold>Configure Footer{'\n'}</Text>
@@ -337,59 +316,65 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
Select which items to display in the footer.
</Text>
<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);
<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;
return (
<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 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>
);
})
)}
}}
/>
</Box>
<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>
<DialogFooter
primaryAction="Enter to select"
navigationActions="↑/↓ to navigate · ←/→ to reorder"
cancelAction="Esc to close"
/>
<Box
marginTop={1}
@@ -399,7 +384,9 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
flexDirection="column"
>
<Text bold>Preview:</Text>
<Box flexDirection="row">{previewContent}</Box>
<Box flexDirection="row" width="100%">
{previewContent}
</Box>
</Box>
</Box>
);
@@ -5,10 +5,20 @@
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { QuotaDisplay } from './QuotaDisplay.js';
describe('QuotaDisplay', () => {
beforeEach(() => {
vi.stubEnv('TZ', 'America/Los_Angeles');
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-02T20:29:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
it('should not render when remaining is undefined', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={undefined} limit={100} />,
@@ -36,7 +46,7 @@ describe('QuotaDisplay', () => {
unmount();
});
it('should not render when usage > 20%', async () => {
it('should not render when usage < 80%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={85} limit={100} />,
);
@@ -45,7 +55,7 @@ describe('QuotaDisplay', () => {
unmount();
});
it('should render yellow when usage < 20%', async () => {
it('should render warning when used >= 80%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={15} limit={100} />,
);
@@ -54,7 +64,7 @@ describe('QuotaDisplay', () => {
unmount();
});
it('should render red when usage < 5%', async () => {
it('should render critical when used >= 95%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={4} limit={100} />,
);
+24 -20
View File
@@ -7,9 +7,9 @@
import type React from 'react';
import { Text } from 'ink';
import {
getStatusColor,
QUOTA_THRESHOLD_HIGH,
QUOTA_THRESHOLD_MEDIUM,
getUsedStatusColor,
QUOTA_USED_WARNING_THRESHOLD,
QUOTA_USED_CRITICAL_THRESHOLD,
} from '../utils/displayUtils.js';
import { formatResetTime } from '../utils/formatters.js';
@@ -34,32 +34,36 @@ export const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
return null;
}
const percentage = (remaining / limit) * 100;
const usedPercentage = 100 - (remaining / limit) * 100;
if (!forceShow && percentage > QUOTA_THRESHOLD_HIGH) {
if (!forceShow && usedPercentage < QUOTA_USED_WARNING_THRESHOLD) {
return null;
}
const color = getStatusColor(percentage, {
green: QUOTA_THRESHOLD_HIGH,
yellow: QUOTA_THRESHOLD_MEDIUM,
const color = getUsedStatusColor(usedPercentage, {
warning: QUOTA_USED_WARNING_THRESHOLD,
critical: QUOTA_USED_CRITICAL_THRESHOLD,
});
const resetInfo =
!terse && resetTime ? `, ${formatResetTime(resetTime)}` : '';
let text: string;
if (remaining === 0) {
let text = terse
? 'Limit reached'
: `/stats Limit reached${resetInfo}${!terse && '. /auth to continue.'}`;
if (lowercase) text = text.toLowerCase();
return <Text color={color}>{text}</Text>;
const resetMsg = resetTime
? `, resets in ${formatResetTime(resetTime, 'terse')}`
: '';
text = terse ? 'Limit reached' : `Limit reached${resetMsg}`;
} else {
text = terse
? `${usedPercentage.toFixed(0)}%`
: `${usedPercentage.toFixed(0)}% used${
resetTime
? ` (Limit resets in ${formatResetTime(resetTime, 'terse')})`
: ''
}`;
}
let text = terse
? `${percentage.toFixed(0)}%`
: `/stats ${percentage.toFixed(0)}% usage remaining${resetInfo}`;
if (lowercase) text = text.toLowerCase();
if (lowercase) {
text = text.toLowerCase();
}
return <Text color={color}>{text}</Text>;
};
@@ -9,9 +9,9 @@ import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { formatResetTime } from '../utils/formatters.js';
import {
getStatusColor,
QUOTA_THRESHOLD_HIGH,
QUOTA_THRESHOLD_MEDIUM,
getUsedStatusColor,
QUOTA_USED_WARNING_THRESHOLD,
QUOTA_USED_CRITICAL_THRESHOLD,
} from '../utils/displayUtils.js';
interface QuotaStatsInfoProps {
@@ -31,19 +31,26 @@ export const QuotaStatsInfo: React.FC<QuotaStatsInfoProps> = ({
return null;
}
const percentage = (remaining / limit) * 100;
const color = getStatusColor(percentage, {
green: QUOTA_THRESHOLD_HIGH,
yellow: QUOTA_THRESHOLD_MEDIUM,
const usedPercentage = 100 - (remaining / limit) * 100;
const color = getUsedStatusColor(usedPercentage, {
warning: QUOTA_USED_WARNING_THRESHOLD,
critical: QUOTA_USED_CRITICAL_THRESHOLD,
});
return (
<Box flexDirection="column" marginTop={0} marginBottom={0}>
<Text color={color}>
{remaining === 0
? `Limit reached`
: `${percentage.toFixed(0)}% usage remaining`}
{resetTime && `, ${formatResetTime(resetTime)}`}
? `Limit reached${
resetTime
? `, resets in ${formatResetTime(resetTime, 'terse')}`
: ''
}`
: `${usedPercentage.toFixed(0)}% used${
resetTime
? ` (Limit resets in ${formatResetTime(resetTime, 'terse')})`
: ''
}`}
</Text>
{showDetails && (
<>
@@ -45,7 +45,7 @@ describe('ShowMoreLines', () => {
},
);
it('renders nothing in STANDARD mode even if overflowing', async () => {
it('renders message in STANDARD mode when overflowing', async () => {
mockUseAlternateBuffer.mockReturnValue(false);
mockUseOverflowState.mockReturnValue({
overflowingIds: new Set(['1']),
@@ -55,7 +55,9 @@ describe('ShowMoreLines', () => {
<ShowMoreLines constrainHeight={true} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
expect(lastFrame().toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
});
@@ -9,7 +9,6 @@ import { useOverflowState } from '../contexts/OverflowContext.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { theme } from '../semantic-colors.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
interface ShowMoreLinesProps {
constrainHeight: boolean;
@@ -20,7 +19,6 @@ export const ShowMoreLines = ({
constrainHeight,
isOverflowing: isOverflowingProp,
}: ShowMoreLinesProps) => {
const isAlternateBuffer = useAlternateBuffer();
const overflowState = useOverflowState();
const streamingState = useStreamingContext();
@@ -29,7 +27,6 @@ export const ShowMoreLines = ({
(overflowState !== undefined && overflowState.overflowingIds.size > 0);
if (
!isAlternateBuffer ||
!isOverflowing ||
!constrainHeight ||
!(
@@ -64,4 +64,32 @@ describe('ShowMoreLines layout and padding', () => {
unmount();
});
it('renders in Standard mode as well', async () => {
mockUseAlternateBuffer.mockReturnValue(false); // Standard mode
const TestComponent = () => (
<Box flexDirection="column">
<Text>Top</Text>
<ShowMoreLines constrainHeight={true} />
<Text>Bottom</Text>
</Box>
);
const { lastFrame, waitUntilReady, unmount } = render(<TestComponent />);
await waitUntilReady();
const output = lastFrame({ allowEmpty: true });
const lines = output.split('\n');
expect(lines).toEqual([
'Top',
' Press Ctrl+O to show more lines',
'',
'Bottom',
'',
]);
unmount();
});
});
@@ -68,6 +68,14 @@ const createTestMetrics = (
});
describe('<StatsDisplay />', () => {
beforeEach(() => {
vi.stubEnv('TZ', 'UTC');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('renders only the Performance section in its zero state', async () => {
const zeroMetrics = createTestMetrics();
@@ -465,9 +473,9 @@ describe('<StatsDisplay />', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Usage remaining');
expect(output).toContain('75.0%');
expect(output).toContain('resets in 1h 30m');
expect(output).toContain('Model usage');
expect(output).toContain('25%');
expect(output).toContain('Usage resets');
expect(output).toMatchSnapshot();
vi.useRealTimers();
@@ -521,8 +529,8 @@ describe('<StatsDisplay />', () => {
await waitUntilReady();
const output = lastFrame();
// (10 + 700) / (100 + 1000) = 710 / 1100 = 64.5%
expect(output).toContain('65% usage remaining');
// (1 - 710/1100) * 100 = 35.5%
expect(output).toContain('35%');
expect(output).toContain('Usage limit: 1,100');
expect(output).toMatchSnapshot();
@@ -571,8 +579,8 @@ describe('<StatsDisplay />', () => {
expect(output).toContain('gemini-2.5-flash');
expect(output).toContain('-'); // for requests
expect(output).toContain('50.0%');
expect(output).toContain('resets in 2h');
expect(output).toContain('50%');
expect(output).toContain('Usage resets');
expect(output).toMatchSnapshot();
vi.useRealTimers();
+210 -100
View File
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Box, Text, useStdout } from 'ink';
import { ThemedGradient } from './ThemedGradient.js';
import { theme } from '../semantic-colors.js';
import { formatDuration, formatResetTime } from '../utils/formatters.js';
@@ -19,6 +19,9 @@ import {
USER_AGREEMENT_RATE_MEDIUM,
CACHE_EFFICIENCY_HIGH,
CACHE_EFFICIENCY_MEDIUM,
getUsedStatusColor,
QUOTA_USED_WARNING_THRESHOLD,
QUOTA_USED_CRITICAL_THRESHOLD,
} from '../utils/displayUtils.js';
import { computeSessionStats } from '../utils/computeStats.js';
import {
@@ -155,6 +158,8 @@ const ModelUsageTable: React.FC<{
useGemini3_1,
useCustomToolModel,
}) => {
const { stdout } = useStdout();
const terminalWidth = stdout?.columns ?? 84;
const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel);
if (rows.length === 0) {
@@ -163,12 +168,47 @@ const ModelUsageTable: React.FC<{
const showQuotaColumn = !!quotas && rows.some((row) => !!row.bucket);
const nameWidth = 25;
const requestsWidth = 7;
const nameWidth = 23;
const requestsWidth = 5;
const uncachedWidth = 15;
const cachedWidth = 14;
const outputTokensWidth = 15;
const usageLimitWidth = showQuotaColumn ? 28 : 0;
const percentageWidth = showQuotaColumn ? 6 : 0;
const resetWidth = 22;
// Total width of other columns (including parent box paddingX={2})
const fixedWidth = nameWidth + requestsWidth + percentageWidth + resetWidth;
const outerPadding = 4;
const availableForUsage = terminalWidth - outerPadding - fixedWidth;
const usageLimitWidth = showQuotaColumn
? Math.max(10, Math.min(24, availableForUsage))
: 0;
const progressBarWidth = Math.max(2, usageLimitWidth - 4);
const renderProgressBar = (
usedFraction: number,
color: string,
totalSteps = 20,
) => {
let filledSteps = Math.round(usedFraction * totalSteps);
// If something is used (fraction > 0) but rounds to 0, show 1 tick.
// If < 100% (fraction < 1) but rounds to 20, show 19 ticks.
if (usedFraction > 0 && usedFraction < 1) {
filledSteps = Math.min(Math.max(filledSteps, 1), totalSteps - 1);
}
const emptySteps = Math.max(0, totalSteps - filledSteps);
return (
<Box flexDirection="row" flexShrink={0}>
<Text wrap="truncate-end">
<Text color={color}>{'▬'.repeat(filledSteps)}</Text>
<Text color={theme.border.default}>{'▬'.repeat(emptySteps)}</Text>
</Text>
</Box>
);
};
const cacheEfficiencyColor = getStatusColor(cacheEfficiency, {
green: CACHE_EFFICIENCY_HIGH,
@@ -179,25 +219,13 @@ const ModelUsageTable: React.FC<{
nameWidth +
requestsWidth +
(showQuotaColumn
? usageLimitWidth
? usageLimitWidth + percentageWidth + resetWidth
: uncachedWidth + cachedWidth + outputTokensWidth);
const isAuto = currentModel && isAutoModel(currentModel);
const modelUsageTitle = isAuto
? `${getDisplayString(currentModel)} Usage`
: `Model Usage`;
return (
<Box flexDirection="column" marginBottom={1}>
{/* Header */}
<Box alignItems="flex-end">
<Box width={nameWidth}>
<Text bold color={theme.text.primary} wrap="truncate-end">
{modelUsageTitle}
</Text>
</Box>
</Box>
{isAuto &&
showQuotaColumn &&
pooledRemaining !== undefined &&
@@ -216,7 +244,7 @@ const ModelUsageTable: React.FC<{
)}
<Box alignItems="flex-end">
<Box width={nameWidth}>
<Box width={nameWidth} flexShrink={0}>
<Text bold color={theme.text.primary}>
Model
</Text>
@@ -267,15 +295,31 @@ const ModelUsageTable: React.FC<{
</>
)}
{showQuotaColumn && (
<Box
width={usageLimitWidth}
flexDirection="column"
alignItems="flex-end"
>
<Text bold color={theme.text.primary}>
Usage remaining
</Text>
</Box>
<>
<Box
width={usageLimitWidth}
flexDirection="column"
alignItems="flex-start"
paddingLeft={4}
flexShrink={0}
>
<Text bold color={theme.text.primary}>
Model usage
</Text>
</Box>
<Box width={percentageWidth} flexShrink={0} />
<Box
width={resetWidth}
flexDirection="column"
alignItems="flex-start"
paddingLeft={2}
flexShrink={0}
>
<Text bold color={theme.text.primary} wrap="truncate-end">
Usage resets
</Text>
</Box>
</>
)}
</Box>
@@ -290,84 +334,150 @@ const ModelUsageTable: React.FC<{
width={totalWidth}
></Box>
{rows.map((row) => (
<Box key={row.key}>
<Box width={nameWidth}>
<Text
color={row.isActive ? theme.text.primary : theme.text.secondary}
wrap="truncate-end"
{rows.map((row) => {
let effectiveUsedFraction = 0;
let usedPercentage = 0;
let statusColor = theme.ui.comment;
let percentageText = '';
if (row.bucket && row.bucket.remainingFraction != null) {
const actualUsedFraction = 1 - row.bucket.remainingFraction;
effectiveUsedFraction =
actualUsedFraction === 0 && row.isActive
? 0.001
: actualUsedFraction;
usedPercentage = effectiveUsedFraction * 100;
statusColor =
getUsedStatusColor(usedPercentage, {
warning: QUOTA_USED_WARNING_THRESHOLD,
critical: QUOTA_USED_CRITICAL_THRESHOLD,
}) ?? (row.isActive ? theme.text.primary : theme.ui.comment);
percentageText =
usedPercentage > 0 && usedPercentage < 1
? `${usedPercentage.toFixed(1)}%`
: `${usedPercentage.toFixed(0)}%`;
}
return (
<Box key={row.key}>
<Box width={nameWidth} flexShrink={0}>
<Text
color={row.isActive ? theme.text.primary : theme.text.secondary}
wrap="truncate-end"
>
{row.modelName}
</Text>
</Box>
<Box
width={requestsWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
{row.modelName}
</Text>
</Box>
<Box
width={requestsWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text
color={row.isActive ? theme.text.primary : theme.text.secondary}
>
{row.requests}
</Text>
</Box>
{!showQuotaColumn && (
<>
<Box
width={uncachedWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
<Text
color={row.isActive ? theme.text.primary : theme.text.secondary}
>
<Text
color={
row.isActive ? theme.text.primary : theme.text.secondary
}
{row.requests}
</Text>
</Box>
{!showQuotaColumn && (
<>
<Box
width={uncachedWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
{row.inputTokens}
</Text>
</Box>
<Box
width={cachedWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text color={theme.text.secondary}>{row.cachedTokens}</Text>
</Box>
<Box
width={outputTokensWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text
color={
row.isActive ? theme.text.primary : theme.text.secondary
}
<Text
color={
row.isActive ? theme.text.primary : theme.text.secondary
}
>
{row.inputTokens}
</Text>
</Box>
<Box
width={cachedWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
{row.outputTokens}
</Text>
</Box>
</>
)}
<Box
width={usageLimitWidth}
flexDirection="column"
alignItems="flex-end"
>
{row.bucket &&
row.bucket.remainingFraction != null &&
row.bucket.resetTime && (
<Text color={theme.text.secondary} wrap="truncate-end">
{(row.bucket.remainingFraction * 100).toFixed(1)}%{' '}
{formatResetTime(row.bucket.resetTime)}
</Text>
)}
<Text color={theme.text.secondary}>{row.cachedTokens}</Text>
</Box>
<Box
width={outputTokensWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text
color={
row.isActive ? theme.text.primary : theme.text.secondary
}
>
{row.outputTokens}
</Text>
</Box>
</>
)}
{showQuotaColumn && (
<>
<Box
width={usageLimitWidth}
flexDirection="column"
alignItems="flex-start"
paddingLeft={4}
flexShrink={0}
>
{row.bucket && row.bucket.remainingFraction != null && (
<Box flexDirection="row" flexShrink={0}>
{renderProgressBar(
effectiveUsedFraction,
statusColor,
progressBarWidth,
)}
</Box>
)}
</Box>
<Box
width={percentageWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
{row.bucket && row.bucket.remainingFraction != null && (
<Box>
{row.bucket.remainingFraction === 0 ? (
<Text color={theme.status.error} wrap="truncate-end">
Limit
</Text>
) : (
<Text color={statusColor} wrap="truncate-end">
{percentageText}
</Text>
)}
</Box>
)}
</Box>
<Box
width={resetWidth}
flexDirection="column"
alignItems="flex-start"
paddingLeft={2}
flexShrink={0}
>
<Text color={theme.text.secondary} wrap="truncate-end">
{row.bucket?.resetTime &&
formatResetTime(row.bucket.resetTime, 'column')
? formatResetTime(row.bucket.resetTime, 'column')
: ''}
</Text>
</Box>
</>
)}
</Box>
</Box>
))}
);
})}
{cacheEfficiency > 0 && !showQuotaColumn && (
<Box flexDirection="column" marginTop={1}>
@@ -54,8 +54,6 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
backgroundShellCount: 0,
buffer: { text: '' },
history: [{ id: 1, type: 'user', text: 'test' }],
sisyphusSecondsRemaining: null,
a2aListenerPort: null,
...overrides,
}) as UIState;
@@ -173,42 +171,4 @@ 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 { Box, Text } from 'ink';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
@@ -24,42 +24,18 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
const settings = useSettings();
const config = useConfig();
const items: React.ReactNode[] = [];
if (process.env['GEMINI_SYSTEM_MD']) {
items.push(<Text color={theme.status.error}>|_|</Text>);
return <Text color={theme.status.error}>|_|</Text>;
}
if (
uiState.activeHooks.length > 0 &&
settings.merged.hooksConfig.notifications
) {
items.push(<HookStatusDisplay activeHooks={uiState.activeHooks} />);
return <HookStatusDisplay activeHooks={uiState.activeHooks} />;
}
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
) {
if (!settings.merged.ui.hideContextSummary && !hideContextSummary) {
return (
<ContextSummaryDisplay
ideContext={uiState.ideContextState}
@@ -75,17 +51,5 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
);
}
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>
);
return null;
};
@@ -188,7 +188,7 @@ describe('ToastDisplay', () => {
});
await waitUntilReady();
expect(lastFrame()).toContain(
'Ctrl+O to show more lines of the last response',
'Press 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}>
Ctrl+O to {action} lines of the last response
Press Ctrl+O to {action} lines of the last response
</Text>
);
}
@@ -15,7 +15,6 @@ import type { ConfirmingToolState } from '../hooks/useConfirmingTool.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { StickyHeader } from './StickyHeader.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import type { SerializableConfirmationDetails } from '@google/gemini-cli-core';
import { useUIActions } from '../contexts/UIActionsContext.js';
@@ -43,7 +42,6 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
}) => {
const config = useConfig();
const { getPreferredEditor } = useUIActions();
const isAlternateBuffer = useAlternateBuffer();
const {
mainAreaWidth,
terminalHeight,
@@ -157,10 +155,5 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
</>
);
return isAlternateBuffer ? (
/* Shadow the global provider to maintain isolation in ASB mode. */
<OverflowProvider>{content}</OverflowProvider>
) : (
content
);
return <OverflowProvider>{content}</OverflowProvider>;
};
@@ -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 15%
" workspace (/directory) sandbox /model /stats
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85%
"
`;
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 85%
" workspace (/directory) sandbox /model /stats
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15%
"
`;
@@ -0,0 +1,159 @@
<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>

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,154 @@
<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>

After

Width:  |  Height:  |  Size: 16 KiB

@@ -1,5 +1,48 @@
// 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`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
@@ -7,28 +50,82 @@ 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)
│ > [✓] 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 │
│ │
↑/↓ navigate · ←/→ reorder · enter/space select · esc close
│ 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% │ │
│ │ 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,7 +18,6 @@ AppHeader(full)
│ Line 19 █ │
│ Line 20 █ │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
Press Ctrl+O to show more lines
"
`;
@@ -40,7 +39,6 @@ AppHeader(full)
│ Line 19 █ │
│ Line 20 █ │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
Press Ctrl+O to show more lines
"
`;
@@ -1,12 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`QuotaDisplay > should NOT render reset time when terse is true 1`] = `
"15%
"85%
"
`;
exports[`QuotaDisplay > should render red when usage < 5% 1`] = `
"/stats 4% usage remaining
exports[`QuotaDisplay > should render critical when used >= 95% 1`] = `
"96% used
"
`;
@@ -15,12 +15,12 @@ exports[`QuotaDisplay > should render terse limit reached message 1`] = `
"
`;
exports[`QuotaDisplay > should render with reset time when provided 1`] = `
"/stats 15% usage remaining, resets in 1h
exports[`QuotaDisplay > should render warning when used >= 80% 1`] = `
"85% used
"
`;
exports[`QuotaDisplay > should render yellow when usage < 20% 1`] = `
"/stats 15% usage remaining
exports[`QuotaDisplay > should render with reset time when provided 1`] = `
"85% used (Limit resets in 1h)
"
`;
@@ -17,10 +17,9 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
│ » API Time: 50.2s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage
Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 10 500 500 2,000 │
│ Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────
gemini-2.5-pro 10 500 500 2,000
│ │
│ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
@@ -117,10 +117,9 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency secti
│ » API Time: 100ms (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage
Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 100 0 100 │
│ Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────
gemini-2.5-pro 1 100 0 100
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -162,16 +161,15 @@ exports[`<StatsDisplay /> > Quota Display > renders pooled quota information for
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
auto Usage
│ 65% usage remaining │
35% used
│ Usage limit: 1,100 │
│ Usage limits span all sessions and reset daily. │
│ For a full token breakdown, run \`/stats model\`. │
│ │
│ Model Reqs Usage remaining
│ ────────────────────────────────────────────────────────────
│ gemini-2.5-pro -
│ gemini-2.5-flash -
│ Model Reqs Model usage Usage resets
│ ────────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 90%
│ gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 30%
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -193,10 +191,9 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information for unused
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage
Model Reqs Usage remaining
────────────────────────────────────────────────────────────
│ gemini-2.5-flash - 50.0% resets in 2h │
│ Model Reqs Model usage Usage resets
────────────────────────────────────────────────────────────────────────────────
gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 50% 2:00 PM (2h)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -218,10 +215,9 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information when quota
│ » API Time: 100ms (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage
Model Reqs Usage remaining
────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 75.0% resets in 1h 30m │
│ Model Reqs Model usage Usage resets
────────────────────────────────────────────────────────────────────────────────
gemini-2.5-pro 1 ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 25% 1:30 PM (1h 30m)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -283,11 +279,10 @@ exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
│ » API Time: 19.5s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage
Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 3 500 500 2,000 │
│ gemini-2.5-flash 5 15,000 10,000 15,000 │
│ Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────
gemini-2.5-pro 3 500 500 2,000
│ gemini-2.5-flash 5 15,000 10,000 15,000
│ │
│ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │
│ │
@@ -312,10 +307,9 @@ exports[`<StatsDisplay /> > renders all sections when all data is present 1`] =
│ » API Time: 100ms (44.8%) │
│ » Tool Time: 123ms (55.2%) │
│ │
│ Model Usage
Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 50 50 100 │
│ Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────
gemini-2.5-pro 1 50 50 100
│ │
│ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
@@ -16,6 +16,7 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai
│ 4. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
Press Ctrl+O to show more lines
"
`;
@@ -27,7 +27,6 @@ export function CompressionMessage({
const originalTokens = originalTokenCount ?? 0;
const newTokens = newTokenCount ?? 0;
const archivePath = compression.archivePath;
const getCompressionText = () => {
if (isPending) {
@@ -37,8 +36,6 @@ 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,12 +7,9 @@
import type React from 'react';
import { Text, Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { ShowMoreLines } from '../ShowMoreLines.js';
import { theme } from '../../semantic-colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import { OverflowProvider } from '../../contexts/OverflowContext.js';
interface GeminiMessageProps {
text: string;
@@ -31,8 +28,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
const prefix = '✦ ';
const prefixWidth = prefix.length;
const isAlternateBuffer = useAlternateBuffer();
const content = (
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.text.accent} aria-label={SCREEN_READER_MODEL_PREFIX}>
@@ -44,26 +40,14 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
text={text}
isPending={isPending}
availableTerminalHeight={
isAlternateBuffer || availableTerminalHeight === undefined
availableTerminalHeight === undefined
? undefined
: Math.max(availableTerminalHeight - 1, 1)
}
terminalWidth={Math.max(terminalWidth - prefixWidth, 0)}
renderMarkdown={renderMarkdown}
/>
<Box>
<ShowMoreLines
constrainHeight={availableTerminalHeight !== undefined}
/>
</Box>
</Box>
</Box>
);
return isAlternateBuffer ? (
/* Shadow the global provider to maintain isolation in ASB mode. */
<OverflowProvider>{content}</OverflowProvider>
) : (
content
);
};
@@ -7,9 +7,7 @@
import type React from 'react';
import { Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { ShowMoreLines } from '../ShowMoreLines.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
interface GeminiMessageContentProps {
text: string;
@@ -31,7 +29,6 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
terminalWidth,
}) => {
const { renderMarkdown } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const originalPrefix = '✦ ';
const prefixWidth = originalPrefix.length;
@@ -41,18 +38,13 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
text={text}
isPending={isPending}
availableTerminalHeight={
isAlternateBuffer || availableTerminalHeight === undefined
availableTerminalHeight === undefined
? undefined
: Math.max(availableTerminalHeight - 1, 1)
}
terminalWidth={Math.max(terminalWidth - prefixWidth, 0)}
renderMarkdown={renderMarkdown}
/>
<Box>
<ShowMoreLines
constrainHeight={availableTerminalHeight !== undefined}
/>
</Box>
</Box>
);
};
@@ -195,7 +195,7 @@ describe('<ShellToolMessage />', () => {
[
'uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large',
100,
ACTIVE_SHELL_MAX_LINES,
ACTIVE_SHELL_MAX_LINES - 3,
false,
],
[
@@ -207,7 +207,7 @@ describe('<ShellToolMessage />', () => {
[
'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined',
undefined,
ACTIVE_SHELL_MAX_LINES,
ACTIVE_SHELL_MAX_LINES - 3,
false,
],
])('%s', async (_, availableTerminalHeight, expectedMaxLines, focused) => {
@@ -301,8 +301,8 @@ describe('<ShellToolMessage />', () => {
await waitUntilReady();
await waitFor(() => {
const frame = lastFrame();
// Should still be constrained to ACTIVE_SHELL_MAX_LINES (15) because isExpandable is false
expect(frame.match(/Line \d+/g)?.length).toBe(15);
// Should still be constrained to 12 (15 - 3) because isExpandable is false
expect(frame.match(/Line \d+/g)?.length).toBe(12);
});
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -24,8 +24,16 @@ import type { ToolMessageProps } from './ToolMessage.js';
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { type Config } from '@google/gemini-cli-core';
import { calculateShellMaxLines } from '../../utils/toolLayoutUtils.js';
import {
type Config,
ShellExecutionService,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import {
calculateShellMaxLines,
calculateToolContentMaxLines,
SHELL_CONTENT_OVERHEAD,
} from '../../utils/toolLayoutUtils.js';
export interface ShellToolMessageProps extends ToolMessageProps {
config?: Config;
@@ -78,6 +86,47 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
embeddedShellFocused,
);
const maxLines = calculateShellMaxLines({
status,
isAlternateBuffer,
isThisShellFocused,
availableTerminalHeight,
constrainHeight,
isExpandable,
});
const availableHeight = calculateToolContentMaxLines({
availableTerminalHeight,
isAlternateBuffer,
maxLinesLimit: maxLines,
});
React.useEffect(() => {
const isExecuting = status === CoreToolCallStatus.Executing;
if (isExecuting && ptyId) {
try {
const childWidth = terminalWidth - 4; // account for padding and borders
const finalHeight =
availableHeight ?? ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD;
ShellExecutionService.resizePty(
ptyId,
Math.max(1, childWidth),
Math.max(1, finalHeight),
);
} catch (e) {
if (
!(
e instanceof Error &&
e.message.includes('Cannot resize a pty that has already exited')
)
) {
throw e;
}
}
}
}, [ptyId, status, terminalWidth, availableHeight]);
const { setEmbeddedShellFocused } = useUIActions();
const wasFocusedRef = React.useRef(false);
@@ -166,14 +215,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
terminalWidth={terminalWidth}
renderOutputAsMarkdown={renderOutputAsMarkdown}
hasFocus={isThisShellFocused}
maxLines={calculateShellMaxLines({
status,
isAlternateBuffer,
isThisShellFocused,
availableTerminalHeight,
constrainHeight,
isExpandable,
})}
maxLines={maxLines}
/>
{isThisShellFocused && config && (
<ShellInputPrompt
@@ -6,7 +6,6 @@
import { renderWithProviders } from '../../../test-utils/render.js';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { act } from 'react';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import type {
HistoryItem,
@@ -767,200 +766,4 @@ describe('<ToolGroupMessage />', () => {
},
);
});
describe('Manual Overflow Detection', () => {
it('detects overflow for string results exceeding available height', async () => {
const toolCalls = [
createToolCall({
resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5',
}),
];
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={6} // Very small height
isExpandable={true}
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
},
},
);
await waitUntilReady();
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
});
it('detects overflow for array results exceeding available height', async () => {
// resultDisplay when array is expected to be AnsiLine[]
// AnsiLine is AnsiToken[]
const toolCalls = [
createToolCall({
resultDisplay: Array(5).fill([{ text: 'line', fg: 'default' }]),
}),
];
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={6}
isExpandable={true}
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
},
},
);
await waitUntilReady();
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
});
it('respects ACTIVE_SHELL_MAX_LINES for focused shell tools', async () => {
const toolCalls = [
createToolCall({
name: 'run_shell_command',
status: CoreToolCallStatus.Executing,
ptyId: 1,
resultDisplay: Array(20).fill('line').join('\n'), // 20 lines > 15 (limit)
}),
];
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={100} // Plenty of terminal height
isExpandable={true}
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
activePtyId: 1,
embeddedShellFocused: true,
},
},
);
await waitUntilReady();
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
});
it('does not show expansion hint when content is within limits', async () => {
const toolCalls = [
createToolCall({
resultDisplay: 'small result',
}),
];
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={20}
isExpandable={true}
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
},
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines');
unmount();
});
it('hides expansion hint when constrainHeight is false', async () => {
const toolCalls = [
createToolCall({
resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5',
}),
];
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={6}
isExpandable={true}
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true,
uiState: {
constrainHeight: false,
},
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines');
unmount();
});
it('isolates overflow hint in ASB mode (ignores global overflow state)', async () => {
// In this test, the tool output is SHORT (no local overflow).
// We will inject a dummy ID into the global overflow state.
// ToolGroupMessage should still NOT show the hint because it calculates
// overflow locally and passes it as a prop.
const toolCalls = [
createToolCall({
resultDisplay: 'short result',
}),
];
const { lastFrame, unmount, waitUntilReady, capturedOverflowActions } =
renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={100}
isExpandable={true}
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
},
},
);
await waitUntilReady();
// Manually trigger a global overflow
act(() => {
expect(capturedOverflowActions).toBeDefined();
capturedOverflowActions!.addOverflowingId('unrelated-global-id');
});
// The hint should NOT appear because ToolGroupMessage is isolated by its prop logic
expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines');
unmount();
});
});
});
@@ -17,18 +17,12 @@ import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { isShellTool, isThisShellFocused } from './ToolShared.js';
import { isShellTool } from './ToolShared.js';
import {
shouldHideToolCall,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { ShowMoreLines } from '../ShowMoreLines.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import {
calculateShellMaxLines,
calculateToolContentMaxLines,
} from '../../utils/toolLayoutUtils.js';
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
import { useSettings } from '../../contexts/SettingsContext.js';
@@ -83,13 +77,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const config = useConfig();
const {
constrainHeight,
activePtyId,
embeddedShellFocused,
backgroundShells,
pendingHistoryItems,
} = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const { borderColor, borderDimColor } = useMemo(
() =>
@@ -149,72 +141,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN;
/*
* ToolGroupMessage calculates its own overflow state locally and passes
* it as a prop to ShowMoreLines. This isolates it from global overflow
* reports in ASB mode, while allowing it to contribute to the global
* 'Toast' hint in Standard mode.
*
* Because of this prop-based isolation and the explicit mode-checks in
* AppContainer, we do not need to shadow the OverflowProvider here.
*/
const hasOverflow = useMemo(() => {
if (!availableTerminalHeightPerToolMessage) return false;
return visibleToolCalls.some((tool) => {
const isShellToolCall = isShellTool(tool.name);
const isFocused = isThisShellFocused(
tool.name,
tool.status,
tool.ptyId,
activePtyId,
embeddedShellFocused,
);
let maxLines: number | undefined;
if (isShellToolCall) {
maxLines = calculateShellMaxLines({
status: tool.status,
isAlternateBuffer,
isThisShellFocused: isFocused,
availableTerminalHeight: availableTerminalHeightPerToolMessage,
constrainHeight,
isExpandable,
});
}
// Standard tools and Shell tools both eventually use ToolResultDisplay's logic.
// ToolResultDisplay uses calculateToolContentMaxLines to find the final line budget.
const contentMaxLines = calculateToolContentMaxLines({
availableTerminalHeight: availableTerminalHeightPerToolMessage,
isAlternateBuffer,
maxLinesLimit: maxLines,
});
if (!contentMaxLines) return false;
if (typeof tool.resultDisplay === 'string') {
const text = tool.resultDisplay;
const hasTrailingNewline = text.endsWith('\n');
const contentText = hasTrailingNewline ? text.slice(0, -1) : text;
const lineCount = contentText.split('\n').length;
return lineCount > contentMaxLines;
}
if (Array.isArray(tool.resultDisplay)) {
return tool.resultDisplay.length > contentMaxLines;
}
return false;
});
}, [
visibleToolCalls,
availableTerminalHeightPerToolMessage,
activePtyId,
embeddedShellFocused,
isAlternateBuffer,
constrainHeight,
isExpandable,
]);
// If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools),
// only render if we need to close a border from previous
// tool groups. borderBottomOverride=true means we must render the closing border;
@@ -307,12 +233,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
/>
)
}
{(borderBottomOverride ?? true) && visibleToolCalls.length > 0 && (
<ShowMoreLines
constrainHeight={constrainHeight && !!isExpandable}
isOverflowing={hasOverflow}
/>
)}
</Box>
);
@@ -98,6 +98,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
status={status}
description={description}
emphasis={emphasis}
progressMessage={progressMessage}
originalRequestName={originalRequestName}
/>
<FocusHint
@@ -8,21 +8,19 @@ import { describe, it, expect } from 'vitest';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { StreamingState, type IndividualToolCallDisplay } from '../../types.js';
import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { waitFor } from '../../../test-utils/async.js';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
import { useOverflowState } from '../../contexts/OverflowContext.js';
describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay synchronization', () => {
it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Alternate Buffer (ASB) mode', async () => {
it('should ensure ToolGroupMessage correctly reports overflow to the global state in Alternate Buffer (ASB) mode', async () => {
/**
* Logic:
* 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool.
* 2. ASB mode reserves 1 + 6 = 7 lines.
* 3. Line budget = 10 - 7 = 3 lines.
* 4. 5 lines of output > 3 lines budget => hasOverflow should be TRUE.
* 1. availableTerminalHeight(13) - staticHeight(1) - ASB Reserved(6) = 6 lines per tool.
* 2. 10 lines of output > 6 lines budget => hasOverflow should be TRUE.
*/
const lines = Array.from({ length: 5 }, (_, i) => `line ${i + 1}`);
const lines = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`);
const resultDisplay = lines.join('\n');
const toolCalls: IndividualToolCallDisplay[] = [
@@ -36,8 +34,15 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
},
];
const { lastFrame } = renderWithProviders(
<OverflowProvider>
let latestOverflowState: ReturnType<typeof useOverflowState>;
const StateCapture = () => {
latestOverflowState = useOverflowState();
return null;
};
const { unmount, waitUntilReady } = renderWithProviders(
<>
<StateCapture />
<ToolGroupMessage
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
@@ -45,7 +50,7 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
terminalWidth={80}
isExpandable={true}
/>
</OverflowProvider>,
</>,
{
uiState: {
streamingState: StreamingState.Idle,
@@ -55,24 +60,26 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
},
);
// In ASB mode, the hint should appear because hasOverflow is now correctly calculated.
await waitFor(() =>
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
),
);
await waitUntilReady();
// To verify that the overflow state was indeed updated by the Scrollable component.
await waitFor(() => {
expect(latestOverflowState?.overflowingIds.size).toBeGreaterThan(0);
});
unmount();
});
it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Standard mode', async () => {
it('should ensure ToolGroupMessage correctly reports overflow in Standard mode', async () => {
/**
* Logic:
* 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool.
* 2. Standard mode reserves 1 + 2 = 3 lines.
* 3. Line budget = 10 - 3 = 7 lines.
* 4. 9 lines of output > 7 lines budget => hasOverflow should be TRUE.
* 1. availableTerminalHeight(13) passed to ToolGroupMessage.
* 2. ToolGroupMessage subtracts its static height (2) => 11 lines available for tools.
* 3. ToolResultDisplay gets 11 lines, subtracts static height (1) and Standard Reserved (2) => 8 lines.
* 4. 15 lines of output > 8 lines budget => hasOverflow should be TRUE.
*/
const lines = Array.from({ length: 9 }, (_, i) => `line ${i + 1}`);
const lines = Array.from({ length: 15 }, (_, i) => `line ${i + 1}`);
const resultDisplay = lines.join('\n');
const toolCalls: IndividualToolCallDisplay[] = [
@@ -86,16 +93,14 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
},
];
const { lastFrame } = renderWithProviders(
<OverflowProvider>
<ToolGroupMessage
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={13}
terminalWidth={80}
isExpandable={true}
/>
</OverflowProvider>,
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={13}
terminalWidth={80}
isExpandable={true}
/>,
{
uiState: {
streamingState: StreamingState.Idle,
@@ -105,11 +110,11 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
},
);
await waitUntilReady();
// Verify truncation is occurring (standard mode uses MaxSizedBox)
await waitFor(() => expect(lastFrame()).toContain('hidden (Ctrl+O'));
// In Standard mode, ToolGroupMessage calculates hasOverflow correctly now.
// While Standard mode doesn't render the inline hint (ShowMoreLines returns null),
// the logic inside ToolGroupMessage is now synchronized.
unmount();
});
});
@@ -236,6 +236,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
maxHeight={maxLines ?? availableHeight}
hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down)
scrollToBottom={true}
reportOverflow={true}
>
{content}
</Scrollable>
@@ -1,79 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { StreamingState, type IndividualToolCallDisplay } from '../../types.js';
import { waitFor } from '../../../test-utils/async.js';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
describe('ToolResultDisplay Overflow', () => {
it('should display "press ctrl-o" hint when content overflows in ToolGroupMessage', async () => {
// Large output that will definitely overflow
const lines = [];
for (let i = 0; i < 50; i++) {
lines.push(`line ${i + 1}`);
}
const resultDisplay = lines.join('\n');
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'call-1',
name: 'test-tool',
description: 'a test tool',
status: CoreToolCallStatus.Success,
resultDisplay,
confirmationDetails: undefined,
},
];
const { lastFrame, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={15} // Small height to force overflow
terminalWidth={80}
isExpandable={true}
/>,
{
uiState: {
streamingState: StreamingState.Idle,
constrainHeight: true,
},
useAlternateBuffer: true,
},
);
await waitUntilReady();
// In ASB mode the overflow hint can render before the scroll position
// settles. Wait for both the hint and the tail of the content so this
// snapshot is deterministic across slower CI runners.
await waitFor(() => {
const frame = lastFrame();
expect(frame).toBeDefined();
expect(frame?.toLowerCase()).toContain('press ctrl+o to show more lines');
expect(frame).toContain('line 50');
});
const frame = lastFrame();
expect(frame).toBeDefined();
if (frame) {
expect(frame.toLowerCase()).toContain('press ctrl+o to show more lines');
// Ensure it's AFTER the bottom border
const linesOfOutput = frame.split('\n');
const bottomBorderIndex = linesOfOutput.findLastIndex((l) =>
l.includes('╰─'),
);
const hintIndex = linesOfOutput.findIndex((l) =>
l.toLowerCase().includes('press ctrl+o to show more lines'),
);
expect(hintIndex).toBeGreaterThan(bottomBorderIndex);
expect(frame).toMatchSnapshot();
}
});
});
@@ -192,6 +192,7 @@ type ToolInfoProps = {
description: string;
status: CoreToolCallStatus;
emphasis: TextEmphasis;
progressMessage?: string;
originalRequestName?: string;
};
@@ -200,6 +201,7 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
description,
status: coreStatus,
emphasis,
progressMessage: _progressMessage,
originalRequestName,
}) => {
const status = mapCoreStatusToDisplayStatus(coreStatus);
@@ -4,9 +4,6 @@ exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MA
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command A shell command │
│ │
│ Line 86 │
│ Line 87 │
│ Line 88 │
│ Line 89 │
│ Line 90 │
│ Line 91 │
@@ -16,8 +13,8 @@ exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MA
│ Line 95 │
│ Line 96 │
│ Line 97 │
│ Line 98
│ Line 99
│ Line 98
│ Line 99
│ Line 100 █ │
"
`;
@@ -148,9 +145,6 @@ exports[`<ShellToolMessage /> > Height Constraints > stays constrained in altern
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command A shell command │
│ │
│ Line 86 │
│ Line 87 │
│ Line 88 │
│ Line 89 │
│ Line 90 │
│ Line 91 │
@@ -160,8 +154,8 @@ exports[`<ShellToolMessage /> > Height Constraints > stays constrained in altern
│ Line 95 │
│ Line 96 │
│ Line 97 │
│ Line 98
│ Line 99
│ Line 98
│ Line 99
│ Line 100 █ │
"
`;
@@ -170,9 +164,6 @@ exports[`<ShellToolMessage /> > Height Constraints > uses ACTIVE_SHELL_MAX_LINES
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command A shell command │
│ │
│ Line 86 │
│ Line 87 │
│ Line 88 │
│ Line 89 │
│ Line 90 │
│ Line 91 │
@@ -182,8 +173,8 @@ exports[`<ShellToolMessage /> > Height Constraints > uses ACTIVE_SHELL_MAX_LINES
│ Line 95 │
│ Line 96 │
│ Line 97 │
│ Line 98
│ Line 99
│ Line 98
│ Line 99
│ Line 100 █ │
"
`;
@@ -33,6 +33,7 @@ export interface BaseSelectionListProps<
wrapAround?: boolean;
focusKey?: string;
priority?: boolean;
selectedIndicator?: string;
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
}
@@ -65,6 +66,7 @@ export function BaseSelectionList<
wrapAround = true,
focusKey,
priority,
selectedIndicator = '●',
renderItem,
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
const { activeIndex } = useSelectionList({
@@ -148,7 +150,7 @@ export function BaseSelectionList<
color={isSelected ? theme.ui.focus : theme.text.primary}
aria-hidden
>
{isSelected ? '●' : ' '}
{isSelected ? selectedIndicator : ' '}
</Text>
</Box>
@@ -5,13 +5,22 @@
*/
import type React from 'react';
import { useState, useRef, useCallback, useMemo, useLayoutEffect } from 'react';
import {
useState,
useRef,
useCallback,
useMemo,
useLayoutEffect,
useEffect,
useId,
} from 'react';
import { Box, ResizeObserver, type DOMElement } from 'ink';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { useScrollable } from '../../contexts/ScrollProvider.js';
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { useOverflowActions } from '../../contexts/OverflowContext.js';
interface ScrollableProps {
children?: React.ReactNode;
@@ -22,6 +31,7 @@ interface ScrollableProps {
hasFocus: boolean;
scrollToBottom?: boolean;
flexGrow?: number;
reportOverflow?: boolean;
}
export const Scrollable: React.FC<ScrollableProps> = ({
@@ -33,10 +43,13 @@ export const Scrollable: React.FC<ScrollableProps> = ({
hasFocus,
scrollToBottom,
flexGrow,
reportOverflow = false,
}) => {
const [scrollTop, setScrollTop] = useState(0);
const viewportRef = useRef<DOMElement | null>(null);
const contentRef = useRef<DOMElement | null>(null);
const overflowActions = useOverflowActions();
const id = useId();
const [size, setSize] = useState({
innerHeight: typeof height === 'number' ? height : 0,
scrollHeight: 0,
@@ -52,6 +65,27 @@ export const Scrollable: React.FC<ScrollableProps> = ({
scrollTopRef.current = scrollTop;
}, [scrollTop]);
useEffect(() => {
if (reportOverflow && size.scrollHeight > size.innerHeight) {
overflowActions?.addOverflowingId?.(id);
} else {
overflowActions?.removeOverflowingId?.(id);
}
}, [
reportOverflow,
size.scrollHeight,
size.innerHeight,
id,
overflowActions,
]);
useEffect(
() => () => {
overflowActions?.removeOverflowingId?.(id);
},
[id, overflowActions],
);
const viewportObserverRef = useRef<ResizeObserver | null>(null);
const contentObserverRef = useRef<ResizeObserver | null>(null);
+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 refresh loads context files (/settings)…',
'Control how /memory reload loads context files (/settings)…',
'Toggle respect for .gitignore files in context (/settings)…',
'Toggle respect for .geminiignore files in context (/settings)…',
'Enable recursive file search for @-file completions (/settings)…',
@@ -142,10 +142,10 @@ export const INFORMATIVE_TIPS = [
'Create a project-specific GEMINI.md file with /init…',
'List configured MCP servers and tools with /mcp list…',
'Authenticate with an OAuth-enabled MCP server with /mcp auth…',
'Restart MCP servers with /mcp refresh…',
'Reload MCP servers with /mcp reload…',
'See the current instructional context with /memory show…',
'Add content to the instructional memory with /memory add…',
'Reload instructional context from GEMINI.md files with /memory refresh…',
'Reload instructional context from GEMINI.md files with /memory reload…',
'List the paths of the GEMINI.md files in use with /memory list…',
'Choose your Gemini model with /model…',
'Display the privacy notice with /privacy…',
@@ -227,8 +227,6 @@ export interface UIState {
text: string;
type: TransientMessageType;
} | null;
sisyphusSecondsRemaining: number | null;
a2aListenerPort: number | null;
}
export const UIStateContext = createContext<UIState | null>(null);
@@ -502,7 +502,9 @@ export const useSlashCommandProcessor = (
const props = result.props as Record<string, unknown>;
if (
!props ||
// eslint-disable-next-line no-restricted-syntax
typeof props['name'] !== 'string' ||
// eslint-disable-next-line no-restricted-syntax
typeof props['displayName'] !== 'string' ||
!props['definition']
) {
@@ -325,7 +325,6 @@ describe('toolMapping', () => {
const result = mapToDisplay(toolCall);
expect(result.tools[0].originalRequestName).toBe('original_tool');
});
it('propagates isClientInitiated from tool request', () => {
const clientInitiatedTool: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
@@ -297,13 +297,8 @@ 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();
+19 -185
View File
@@ -37,8 +37,6 @@ import {
buildUserSteeringHintPrompt,
GeminiCliOperation,
getPlanModeExitMessage,
CompressionStatus,
SCHEDULE_WORK_TOOL_NAME,
} from '@google/gemini-cli-core';
import type {
Config,
@@ -60,6 +58,7 @@ import type {
HistoryItemThinking,
HistoryItemWithoutId,
HistoryItemToolGroup,
HistoryItemInfo,
IndividualToolCallDisplay,
SlashCommandProcessorResult,
HistoryItemModel,
@@ -230,27 +229,6 @@ 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);
@@ -1082,37 +1060,31 @@ 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;
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).`;
const limit = tokenLimit(config.getModel());
const originalPercentage = Math.round(
((eventValue?.originalTokenCount ?? 0) / limit) * 100,
);
const newPercentage = Math.round(
((eventValue?.newTokenCount ?? 0) / limit) * 100,
);
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(
{
type: MessageType.INFO,
text: `Context compressed from ${originalPercentage}% to ${newPercentage}%.`,
secondaryText: `Change threshold in /settings.`,
color: theme.status.warning,
marginBottom: 1,
} as HistoryItemInfo,
userMessageTimestamp,
);
},
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
[addItem, pendingHistoryItemRef, setPendingHistoryItem, config],
);
const handleMaxSessionTurnsEvent = useCallback(
@@ -1276,17 +1248,6 @@ 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:
@@ -1408,10 +1369,6 @@ 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);
@@ -1431,35 +1388,6 @@ 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,
@@ -1520,7 +1448,6 @@ 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
@@ -1947,98 +1874,6 @@ 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,
@@ -2064,6 +1899,5 @@ export const useGeminiStream = (
backgroundShells,
dismissBackgroundShell,
retryStatus,
sisyphusSecondsRemaining,
};
};
@@ -81,6 +81,8 @@ describe('useSelectionList', () => {
isFocused?: boolean;
showNumbers?: boolean;
wrapAround?: boolean;
focusKey?: string;
priority?: boolean;
}) => {
let hookResult: ReturnType<typeof useSelectionList>;
function TestComponent(props: typeof initialProps) {
@@ -771,6 +773,67 @@ 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,8 +213,7 @@ function selectionListReducer(
case 'INITIALIZE': {
const { initialIndex, items, wrapAround } = action.payload;
const activeKey =
initialIndex === state.initialIndex &&
state.activeIndex !== state.initialIndex
initialIndex === state.initialIndex
? state.items[state.activeIndex]?.key
: undefined;
@@ -76,17 +76,12 @@ 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,
compressionIndex,
),
convertSessionToClientHistory(conversation.messages),
resumedSessionData,
);
} catch (error) {
@@ -109,18 +109,12 @@ 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,
compressionIndex,
),
convertSessionToClientHistory(resumedSessionData.conversation.messages),
resumedSessionData,
);
}
+14 -14
View File
@@ -117,6 +117,20 @@ export function useToolScheduler(
const handler = (event: ToolCallsUpdateMessage) => {
const isRoot = event.schedulerId === ROOT_SCHEDULER_ID;
// Update output timer for UI spinners (Side Effect)
const hasExecuting = event.toolCalls.some(
(tc) =>
tc.status === CoreToolCallStatus.Executing ||
((tc.status === CoreToolCallStatus.Success ||
tc.status === CoreToolCallStatus.Error) &&
'tailToolCallRequest' in tc &&
tc.tailToolCallRequest != null),
);
if (hasExecuting) {
setLastToolOutputTime(Date.now());
}
setToolCallsMap((prev) => {
const prevCalls = prev[event.schedulerId] ?? [];
const prevCallIds = new Set(prevCalls.map((tc) => tc.request.callId));
@@ -151,20 +165,6 @@ export function useToolScheduler(
[event.schedulerId]: adapted,
};
});
// Update output timer for UI spinners (Side Effect)
const hasExecuting = event.toolCalls.some(
(tc) =>
tc.status === CoreToolCallStatus.Executing ||
((tc.status === CoreToolCallStatus.Success ||
tc.status === CoreToolCallStatus.Error) &&
'tailToolCallRequest' in tc &&
tc.tailToolCallRequest != null),
);
if (hasExecuting) {
setLastToolOutputTime(Date.now());
}
};
messageBus.subscribe(MessageBusType.TOOL_CALLS_UPDATE, handler);
-1
View File
@@ -122,7 +122,6 @@ export interface CompressionProps {
originalTokenCount: number | null;
newTokenCount: number | null;
compressionStatus: CompressionStatus | null;
archivePath?: string;
}
/**
+19
View File
@@ -19,6 +19,9 @@ export const CACHE_EFFICIENCY_MEDIUM = 15;
export const QUOTA_THRESHOLD_HIGH = 20;
export const QUOTA_THRESHOLD_MEDIUM = 5;
export const QUOTA_USED_WARNING_THRESHOLD = 80;
export const QUOTA_USED_CRITICAL_THRESHOLD = 95;
// --- Color Logic ---
export const getStatusColor = (
value: number,
@@ -36,3 +39,19 @@ export const getStatusColor = (
}
return options.defaultColor ?? theme.status.error;
};
/**
* Gets the status color based on "used" percentage (where higher is worse).
*/
export const getUsedStatusColor = (
usedPercentage: number,
thresholds: { warning: number; critical: number },
) => {
if (usedPercentage >= thresholds.critical) {
return theme.status.error;
}
if (usedPercentage >= thresholds.warning) {
return theme.status.warning;
}
return undefined;
};
@@ -10,9 +10,45 @@ import {
formatBytes,
formatTimeAgo,
stripReferenceContent,
formatResetTime,
} from './formatters.js';
describe('formatters', () => {
describe('formatResetTime', () => {
const NOW = new Date('2025-01-01T12:00:00Z');
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it('should format full time correctly', () => {
const resetTime = new Date(NOW.getTime() + 90 * 60 * 1000).toISOString(); // 1h 30m
const result = formatResetTime(resetTime);
expect(result).toMatch(/1 hour 30 minutes at \d{1,2}:\d{2} [AP]M/);
});
it('should format terse time correctly', () => {
const resetTime = new Date(NOW.getTime() + 90 * 60 * 1000).toISOString(); // 1h 30m
expect(formatResetTime(resetTime, 'terse')).toBe('1h 30m');
});
it('should format column time correctly', () => {
const resetTime = new Date(NOW.getTime() + 90 * 60 * 1000).toISOString(); // 1h 30m
const result = formatResetTime(resetTime, 'column');
expect(result).toMatch(/\d{1,2}:\d{2} [AP]M \(1h 30m\)/);
});
it('should handle zero or negative diff by returning empty string', () => {
const resetTime = new Date(NOW.getTime() - 1000).toISOString();
expect(formatResetTime(resetTime)).toBe('');
});
});
describe('formatBytes', () => {
it('should format bytes into KB', () => {
expect(formatBytes(12345)).toBe('12.1 KB');
+45 -13
View File
@@ -98,26 +98,58 @@ export function stripReferenceContent(text: string): string {
return text.replace(pattern, '').trim();
}
export const formatResetTime = (resetTime: string): string => {
const diff = new Date(resetTime).getTime() - Date.now();
export const formatResetTime = (
resetTime: string | undefined,
format: 'terse' | 'column' | 'full' = 'full',
): string => {
if (!resetTime) return '';
const resetDate = new Date(resetTime);
if (isNaN(resetDate.getTime())) return '';
const diff = resetDate.getTime() - Date.now();
if (diff <= 0) return '';
const totalMinutes = Math.ceil(diff / (1000 * 60));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const fmt = (val: number, unit: 'hour' | 'minute') =>
new Intl.NumberFormat('en', {
style: 'unit',
unit,
unitDisplay: 'narrow',
}).format(val);
const isTerse = format === 'terse';
const isColumn = format === 'column';
if (hours > 0 && minutes > 0) {
return `resets in ${fmt(hours, 'hour')} ${fmt(minutes, 'minute')}`;
} else if (hours > 0) {
return `resets in ${fmt(hours, 'hour')}`;
if (isTerse || isColumn) {
const hoursStr = hours > 0 ? `${hours}h` : '';
const minutesStr = minutes > 0 ? `${minutes}m` : '';
const duration =
hoursStr && minutesStr
? `${hoursStr} ${minutesStr}`
: hoursStr || minutesStr;
if (isColumn) {
const timeStr = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: 'numeric',
}).format(resetDate);
return duration ? `${timeStr} (${duration})` : timeStr;
}
return duration;
}
return `resets in ${fmt(minutes, 'minute')}`;
let duration = '';
if (hours > 0) {
duration = `${hours} hour${hours > 1 ? 's' : ''}`;
if (minutes > 0) {
duration += ` ${minutes} minute${minutes > 1 ? 's' : ''}`;
}
} else {
duration = `${minutes} minute${minutes > 1 ? 's' : ''}`;
}
const timeStr = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: 'numeric',
timeZoneName: 'short',
}).format(resetDate);
return `${duration} at ${timeStr}`;
};
+12 -3
View File
@@ -20,6 +20,13 @@ export const TOOL_RESULT_ASB_RESERVED_LINE_COUNT = 6;
export const TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT = 2;
export const TOOL_RESULT_MIN_LINES_SHOWN = 2;
/**
* The vertical space (in lines) consumed by the shell UI elements
* (1 line for the shell title/header and 2 lines for the top and bottom borders).
*/
export const SHELL_CONTENT_OVERHEAD =
TOOL_RESULT_STATIC_HEIGHT + TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT;
/**
* Calculates the final height available for the content of a tool result display.
*
@@ -88,7 +95,9 @@ export function calculateShellMaxLines(options: {
// 2. Handle cases where height is unknown (Standard mode history).
if (availableTerminalHeight === undefined) {
return isAlternateBuffer ? ACTIVE_SHELL_MAX_LINES : undefined;
return isAlternateBuffer
? ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD
: undefined;
}
const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2);
@@ -103,8 +112,8 @@ export function calculateShellMaxLines(options: {
// 4. Fall back to process-based constants.
const isExecuting = status === CoreToolCallStatus.Executing;
const shellMaxLinesLimit = isExecuting
? ACTIVE_SHELL_MAX_LINES
: COMPLETED_SHELL_MAX_LINES;
? ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD
: COMPLETED_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD;
return Math.min(maxLinesBasedOnHeight, shellMaxLinesLimit);
}
+6 -4
View File
@@ -494,9 +494,10 @@ export class ActivityLogger extends EventEmitter {
req.write = function (chunk: string | Uint8Array, ...etc: unknown[]) {
if (chunk) {
const arg0 = etc[0];
const encoding =
typeof etc[0] === 'string' && Buffer.isEncoding(etc[0])
? etc[0]
typeof arg0 === 'string' && Buffer.isEncoding(arg0)
? arg0
: undefined;
requestChunks.push(
Buffer.isBuffer(chunk)
@@ -519,9 +520,10 @@ export class ActivityLogger extends EventEmitter {
) {
const chunk = typeof chunkOrCb === 'function' ? undefined : chunkOrCb;
if (chunk) {
const arg0 = etc[0];
const encoding =
typeof etc[0] === 'string' && Buffer.isEncoding(etc[0])
? etc[0]
typeof arg0 === 'string' && Buffer.isEncoding(arg0)
? arg0
: undefined;
requestChunks.push(
Buffer.isBuffer(chunk)
-4
View File
@@ -23,8 +23,6 @@ export enum AppEvent {
PasteTimeout = 'paste-timeout',
TerminalBackground = 'terminal-background',
TransientMessage = 'transient-message',
ExternalMessage = 'external-message',
A2AListenerStarted = 'a2a-listener-started',
}
export interface AppEvents {
@@ -34,8 +32,6 @@ export interface AppEvents {
[AppEvent.PasteTimeout]: never[];
[AppEvent.TerminalBackground]: [string];
[AppEvent.TransientMessage]: [TransientMessagePayload];
[AppEvent.ExternalMessage]: [string];
[AppEvent.A2AListenerStarted]: [number];
}
export const appEvents = new EventEmitter<AppEvents>();
+1 -84
View File
@@ -10,16 +10,10 @@ import {
extractFirstUserMessage,
formatRelativeTime,
hasUserOrAssistantMessage,
convertSessionToHistoryFormats,
SessionError,
} from './sessionUtils.js';
import type {
Config,
MessageRecord,
ConversationRecord,
} from '@google/gemini-cli-core';
import type { Config, MessageRecord } 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';
@@ -771,80 +765,3 @@ 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',
});
});
});
+1 -17
View File
@@ -526,31 +526,15 @@ 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;
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) {
for (const msg of messages) {
// 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