Merge branch 'main' into gemini-cli-policies-ui

This commit is contained in:
Jack Wotherspoon
2026-03-14 19:13:11 +01:00
committed by GitHub
814 changed files with 40893 additions and 12478 deletions
+2 -2
View File
@@ -156,7 +156,7 @@ export function colorizeCode({
try {
// Render the HAST tree using the adapted theme
// Apply the theme's default foreground color to the top-level Text element
let lines = codeToHighlight.split('\n');
let lines = codeToHighlight.split(/\r?\n/);
const padWidth = String(lines.length).length; // Calculate padding width based on number of lines
let hiddenLinesCount = 0;
@@ -225,7 +225,7 @@ export function colorizeCode({
);
// Fall back to plain text with default color on error
// Also display line numbers in fallback
const lines = codeToHighlight.split('\n');
const lines = codeToHighlight.split(/\r?\n/);
const padWidth = String(lines.length).length; // Calculate padding width based on number of lines
const fallbackLines = lines.map((line, index) => (
<Box key={index} minHeight={1}>
@@ -414,6 +414,7 @@ const RenderListItemInternal: React.FC<RenderListItemProps> = ({
}) => {
const prefix = type === 'ol' ? `${marker}. ` : `${marker} `;
const prefixWidth = prefix.length;
// Account for leading whitespace (indentation level) plus the standard prefix padding
const indentation = leadingWhitespace.length;
const listResponseColor = theme.text.response ?? theme.text.primary;
@@ -422,7 +423,7 @@ const RenderListItemInternal: React.FC<RenderListItemProps> = ({
paddingLeft={indentation + LIST_ITEM_PREFIX_PADDING}
flexDirection="row"
>
<Box width={prefixWidth}>
<Box width={prefixWidth} flexShrink={0}>
<Text color={listResponseColor}>{prefix}</Text>
</Box>
<Box flexGrow={LIST_ITEM_TEXT_FLEX_GROW}>
@@ -4,8 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Mock } from 'vitest';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
import { EventEmitter } from 'node:events';
import clipboardy from 'clipboardy';
import {
+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}`;
};
+1 -1
View File
@@ -25,7 +25,7 @@ export type HighlightToken = {
// It matches any character except strict delimiters (ASCII whitespace, comma, etc.).
// This supports URIs like `@file:///example.txt` and filenames with Unicode spaces (like NNBSP).
const HIGHLIGHT_REGEX = new RegExp(
`(^/[a-zA-Z0-9_-]+|@${AT_COMMAND_PATH_REGEX_SOURCE}|${PASTED_TEXT_PLACEHOLDER_REGEX.source})`,
`(^/[a-zA-Z0-9_-]+|(?<!\\\\)@${AT_COMMAND_PATH_REGEX_SOURCE}|${PASTED_TEXT_PLACEHOLDER_REGEX.source})`,
'g',
);
@@ -11,7 +11,9 @@ import type { Content } from '@google/genai';
/**
* Serializes chat history to a Markdown string.
*/
export function serializeHistoryToMarkdown(history: Content[]): string {
export function serializeHistoryToMarkdown(
history: readonly Content[],
): string {
return history
.map((item) => {
const text =
@@ -49,7 +51,7 @@ export function serializeHistoryToMarkdown(history: Content[]): string {
* Options for exporting chat history.
*/
export interface ExportHistoryOptions {
history: Content[];
history: readonly Content[];
filePath: string;
}
@@ -1,143 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { formatKeyBinding, formatCommand } from './keybindingUtils.js';
import { Command } from '../../config/keyBindings.js';
import type { KeyBinding } from '../../config/keyBindings.js';
describe('keybindingUtils', () => {
describe('formatKeyBinding', () => {
const testCases: Array<{
name: string;
binding: KeyBinding;
expected: {
darwin: string;
win32: string;
linux: string;
default: string;
};
}> = [
{
name: 'simple key',
binding: { key: 'a' },
expected: { darwin: 'A', win32: 'A', linux: 'A', default: 'A' },
},
{
name: 'named key (return)',
binding: { key: 'return' },
expected: {
darwin: 'Enter',
win32: 'Enter',
linux: 'Enter',
default: 'Enter',
},
},
{
name: 'named key (escape)',
binding: { key: 'escape' },
expected: { darwin: 'Esc', win32: 'Esc', linux: 'Esc', default: 'Esc' },
},
{
name: 'ctrl modifier',
binding: { key: 'c', ctrl: true },
expected: {
darwin: 'Ctrl+C',
win32: 'Ctrl+C',
linux: 'Ctrl+C',
default: 'Ctrl+C',
},
},
{
name: 'cmd modifier',
binding: { key: 'z', cmd: true },
expected: {
darwin: 'Cmd+Z',
win32: 'Win+Z',
linux: 'Super+Z',
default: 'Cmd/Win+Z',
},
},
{
name: 'alt/option modifier',
binding: { key: 'left', alt: true },
expected: {
darwin: 'Option+Left',
win32: 'Alt+Left',
linux: 'Alt+Left',
default: 'Alt+Left',
},
},
{
name: 'shift modifier',
binding: { key: 'up', shift: true },
expected: {
darwin: 'Shift+Up',
win32: 'Shift+Up',
linux: 'Shift+Up',
default: 'Shift+Up',
},
},
{
name: 'multiple modifiers (ctrl+shift)',
binding: { key: 'z', ctrl: true, shift: true },
expected: {
darwin: 'Ctrl+Shift+Z',
win32: 'Ctrl+Shift+Z',
linux: 'Ctrl+Shift+Z',
default: 'Ctrl+Shift+Z',
},
},
{
name: 'all modifiers',
binding: { key: 'a', ctrl: true, alt: true, shift: true, cmd: true },
expected: {
darwin: 'Ctrl+Option+Shift+Cmd+A',
win32: 'Ctrl+Alt+Shift+Win+A',
linux: 'Ctrl+Alt+Shift+Super+A',
default: 'Ctrl+Alt+Shift+Cmd/Win+A',
},
},
];
testCases.forEach(({ name, binding, expected }) => {
describe(`${name}`, () => {
it('formats correctly for darwin', () => {
expect(formatKeyBinding(binding, 'darwin')).toBe(expected.darwin);
});
it('formats correctly for win32', () => {
expect(formatKeyBinding(binding, 'win32')).toBe(expected.win32);
});
it('formats correctly for linux', () => {
expect(formatKeyBinding(binding, 'linux')).toBe(expected.linux);
});
it('formats correctly for default', () => {
expect(formatKeyBinding(binding, 'default')).toBe(expected.default);
});
});
});
});
describe('formatCommand', () => {
it('formats default commands (using default platform behavior)', () => {
expect(formatCommand(Command.QUIT, undefined, 'default')).toBe('Ctrl+C');
expect(formatCommand(Command.SUBMIT, undefined, 'default')).toBe('Enter');
expect(
formatCommand(Command.TOGGLE_BACKGROUND_SHELL, undefined, 'default'),
).toBe('Ctrl+B');
});
it('returns empty string for unknown commands', () => {
expect(
formatCommand(
'unknown.command' as unknown as Command,
undefined,
'default',
),
).toBe('');
});
});
});
@@ -1,111 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import process from 'node:process';
import {
type Command,
type KeyBinding,
type KeyBindingConfig,
defaultKeyBindings,
} from '../../config/keyBindings.js';
/**
* Maps internal key names to user-friendly display names.
*/
const KEY_NAME_MAP: Record<string, string> = {
return: 'Enter',
escape: 'Esc',
backspace: 'Backspace',
delete: 'Delete',
up: 'Up',
down: 'Down',
left: 'Left',
right: 'Right',
pageup: 'Page Up',
pagedown: 'Page Down',
home: 'Home',
end: 'End',
tab: 'Tab',
space: 'Space',
'double escape': 'Double Esc',
};
interface ModifierMap {
ctrl: string;
alt: string;
shift: string;
cmd: string;
}
const MODIFIER_MAPS: Record<string, ModifierMap> = {
darwin: {
ctrl: 'Ctrl',
alt: 'Option',
shift: 'Shift',
cmd: 'Cmd',
},
win32: {
ctrl: 'Ctrl',
alt: 'Alt',
shift: 'Shift',
cmd: 'Win',
},
linux: {
ctrl: 'Ctrl',
alt: 'Alt',
shift: 'Shift',
cmd: 'Super',
},
default: {
ctrl: 'Ctrl',
alt: 'Alt',
shift: 'Shift',
cmd: 'Cmd/Win',
},
};
/**
* Formats a single KeyBinding into a human-readable string (e.g., "Ctrl+C").
*/
export function formatKeyBinding(
binding: KeyBinding,
platform?: string,
): string {
const activePlatform =
platform ??
(process.env['FORCE_GENERIC_KEYBINDING_HINTS']
? 'default'
: process.platform);
const modMap = MODIFIER_MAPS[activePlatform] || MODIFIER_MAPS['default'];
const parts: string[] = [];
if (binding.ctrl) parts.push(modMap.ctrl);
if (binding.alt) parts.push(modMap.alt);
if (binding.shift) parts.push(modMap.shift);
if (binding.cmd) parts.push(modMap.cmd);
const keyName = KEY_NAME_MAP[binding.key] || binding.key.toUpperCase();
parts.push(keyName);
return parts.join('+');
}
/**
* Formats the primary keybinding for a command.
*/
export function formatCommand(
command: Command,
config: KeyBindingConfig = defaultKeyBindings,
platform?: string,
): string {
const bindings = config[command];
if (!bindings || bindings.length === 0) {
return '';
}
// Use the first binding as the primary one for display
return formatKeyBinding(bindings[0], platform);
}
+6 -3
View File
@@ -4,9 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Command, keyMatchers } from '../keyMatchers.js';
import { Command } from '../key/keyMatchers.js';
import type { Key } from '../hooks/useKeypress.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
export function shouldDismissShortcutsHelpOnHotkey(key: Key): boolean {
return Object.values(Command).some((command) => keyMatchers[command](key));
export function useIsHelpDismissKey(): (key: Key) => boolean {
const keyMatchers = useKeyMatchers();
return (key: Key) =>
Object.values(Command).some((command) => keyMatchers[command](key));
}
+1 -2
View File
@@ -6,8 +6,7 @@
/// <reference types="vitest/globals" />
import type { MockInstance } from 'vitest';
import { vi } from 'vitest';
import { vi, type MockInstance } from 'vitest';
import { TextOutput } from './textOutput.js';
describe('TextOutput', () => {
+4 -2
View File
@@ -48,12 +48,14 @@ describe('textUtils', () => {
it('should handle unicode characters that crash string-width', () => {
// U+0602 caused string-width to crash (see #16418)
const char = '؂';
expect(getCachedStringWidth(char)).toBe(0);
expect(() => getCachedStringWidth(char)).not.toThrow();
expect(typeof getCachedStringWidth(char)).toBe('number');
});
it('should handle unicode characters that crash string-width with ANSI codes', () => {
const charWithAnsi = '\u001b[31m' + '؂' + '\u001b[0m';
expect(getCachedStringWidth(charWithAnsi)).toBe(0);
expect(() => getCachedStringWidth(charWithAnsi)).not.toThrow();
expect(typeof getCachedStringWidth(charWithAnsi)).toBe('number');
});
});
+13 -4
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.
*
@@ -46,7 +53,7 @@ export function calculateToolContentMaxLines(options: {
)
: undefined;
if (maxLinesLimit) {
if (maxLinesLimit !== undefined) {
contentHeight =
contentHeight !== undefined
? Math.min(contentHeight, maxLinesLimit)
@@ -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);
}