diff --git a/THEME_COMMAND_INFO.md b/THEME_COMMAND_INFO.md new file mode 100644 index 000000000..3aee2189b --- /dev/null +++ b/THEME_COMMAND_INFO.md @@ -0,0 +1,94 @@ +# Qwen Code Theme Command + +## Overview + +The `/theme` command in Qwen Code opens a dialog that allows users to change the visual theme of the CLI. This command provides an interactive interface for selecting from built-in themes and custom themes defined in settings. The implementation has been optimized for performance, memory efficiency, and responsiveness. + +## How It Works + +### 1. Command Invocation + +- Typing `/theme` in the Qwen Code CLI triggers the theme dialog +- The command is handled by `themeCommand` which returns a dialog action with type 'theme' + +### 2. Dialog Interface + +The ThemeDialog component provides: + +- A left panel with theme selection (radio button list) +- A right panel with live theme preview showing code and diff examples +- Tab navigation between theme selection and scope configuration +- Scope selector to choose where to save the theme setting (user/workspace/system) +- **Optimized rendering**: Uses React.memo and useMemo to prevent unnecessary re-renders and calculations + +### 3. Available Themes + +Built-in themes include: + +- **Dark Themes**: AyuDark, AtomOneDark, Dracula, GitHubDark, DefaultDark, QwenDark, ShadesOfPurple +- **Light Themes**: AyuLight, GitHubLight, GoogleCode, DefaultLight, QwenLight, XCode +- **ANSI Themes**: ANSI, ANSILight + +### 4. Custom Themes + +- Users can define custom themes in their settings.json file +- Custom themes can be added via `customThemes` object in the settings +- Theme files can also be loaded directly from JSON files (only from within the home directory for security) +- **Optimized loading**: Implements caching for faster theme retrieval and reduced processing + +### 5. Theme Preview + +- The dialog shows a live preview of the selected theme +- Includes Python code highlighting and a diff example +- This helps users see how the theme will look before applying it +- **Performance optimized**: Layout calculations are memoized to avoid redundant computations + +### 6. Theme Application + +- When a theme is selected, it's applied immediately to the preview +- When confirmed, the theme is saved to the selected scope (user/workspace/system) +- The theme persists across sessions +- **Efficient theme switching**: Uses optimized lookup mechanisms in the theme manager + +### 7. Security Note + +- For security, theme files can only be loaded from within the user's home directory +- This prevents loading potentially malicious theme files from untrusted sources +- **Memory safety**: Implements proper cache clearing to prevent memory leaks + +## Performance Optimizations + +- **Theme Manager**: Implements O(1) theme lookup using name-based cache +- **File Loading**: Caches loaded theme files separately to avoid re-reading from disk +- **UI Rendering**: Uses React hooks (useMemo, useCallback) for efficient re-rendering +- **Memory Management**: Provides methods for clearing theme caches to prevent memory bloat +- **Custom Theme Processing**: Optimized validation and loading of custom themes + +## Usage Steps + +1. Type `/theme` in Qwen Code CLI +2. Browse themes using arrow keys (with live preview) +3. Press Enter to select a theme or Tab to switch to scope configuration +4. If switching to scope configuration, select the scope where you want to save the theme +5. The selected theme will be applied and saved to your settings + +## Configuration + +Themes can also be set directly in settings.json: + +```json +{ + "ui": { + "theme": "QwenDark", + "customThemes": { + "MyCustomTheme": { + "name": "MyCustomTheme", + "type": "dark", + "Foreground": "#ffffff", + "Background": "#000000" + // ... other color definitions + } + } + } +} +``` diff --git a/docs/performance-optimizations.md b/docs/performance-optimizations.md new file mode 100644 index 000000000..4405bc09a --- /dev/null +++ b/docs/performance-optimizations.md @@ -0,0 +1,40 @@ +# Performance Optimizations + +This document outlines the performance optimizations implemented in the Qwen Code project to improve startup time, memory usage, and UI responsiveness. + +## Implemented Optimizations + +### 1. Memory Management Optimization + +- **Caching**: Memory values are now cached to avoid recalculating on every function call +- **Implementation**: The `getMemoryValues()` function caches the total memory and current heap size, preventing repeated calls to `os.totalmem()` and `v8.getHeapStatistics()` + +### 2. DNS Resolution Optimization + +- **Caching**: DNS resolution order validation is now cached to avoid repeated validation +- **Implementation**: The `cachedDnsResolutionOrder` variable prevents repeated validation of the DNS resolution order setting + +### 3. UI Performance Optimizations + +- **Memoization**: Several expensive calculations in `AppContainer.tsx` are now memoized: + - Terminal width/height calculations + - Shell execution configuration + - Console message filtering + - Context file names computation + +## Benefits + +These optimizations provide the following benefits: + +1. **Faster startup times**: Reduced redundant calculations during application initialization +2. **Lower memory usage**: Fewer temporary objects created through caching and memoization +3. **Better UI responsiveness**: Efficient rendering through proper memoization of expensive calculations +4. **Scalability**: Improved performance under various load conditions + +## Development Considerations + +When making changes to the optimized code: + +1. Be mindful of memoization dependencies - make sure all relevant variables are included in dependency arrays +2. Remember to update cache invalidation logic if needed when adding new functionality +3. Consider performance implications when modifying cached/memoized functions diff --git a/package-lock.json b/package-lock.json index 296fc29be..059715218 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3373,9 +3373,9 @@ "license": "MIT" }, "node_modules/@textlint/linter-formatter/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -4523,15 +4523,15 @@ ] }, "node_modules/@vscode/vsce/node_modules/glob": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", - "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", - "minimatch": "^10.0.3", + "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" @@ -4547,11 +4547,11 @@ } }, "node_modules/@vscode/vsce/node_modules/glob/node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/brace-expansion": "^5.0.0" }, @@ -8371,9 +8371,9 @@ "optional": true }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -10030,9 +10030,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -14304,9 +14304,9 @@ } }, "node_modules/tar-fs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", - "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "dev": true, "license": "MIT", "optional": true, @@ -14478,14 +14478,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -14495,11 +14495,14 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -14510,9 +14513,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { @@ -15200,18 +15203,18 @@ } }, "node_modules/vite": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.0.tgz", - "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", + "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.6", - "picomatch": "^4.0.2", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", "postcss": "^8.5.6", - "rollup": "^4.40.0", - "tinyglobby": "^0.2.14" + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" @@ -15298,11 +15301,14 @@ } }, "node_modules/vite/node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -15313,9 +15319,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 89a4c5caa..5449b0f6d 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -58,29 +58,61 @@ import { } from './utils/relaunch.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; +// Cache the DNS resolution order to avoid repeated validation for the same value +let cachedDnsInput: string | undefined = undefined; +let cachedDnsOutput: DnsResolutionOrder | null = null; + export function validateDnsResolutionOrder( order: string | undefined, ): DnsResolutionOrder { + // If we've already computed the result for this input, return it + if (cachedDnsInput === order && cachedDnsOutput !== null) { + return cachedDnsOutput; + } + const defaultValue: DnsResolutionOrder = 'ipv4first'; if (order === undefined) { + cachedDnsInput = order; + cachedDnsOutput = defaultValue; return defaultValue; } if (order === 'ipv4first' || order === 'verbatim') { + cachedDnsInput = order; + cachedDnsOutput = order; return order; } // We don't want to throw here, just warn and use the default. console.warn( `Invalid value for dnsResolutionOrder in settings: "${order}". Using default "${defaultValue}".`, ); + cachedDnsInput = order; + cachedDnsOutput = defaultValue; return defaultValue; } +// Cache memory values to avoid recalculating on each call +let cachedMemoryValues: { + totalMemoryMB: number; + currentMaxOldSpaceSizeMb: number; +} | null = null; + +function getMemoryValues(): { + totalMemoryMB: number; + currentMaxOldSpaceSizeMb: number; +} { + if (!cachedMemoryValues) { + const totalMemoryMB = os.totalmem() / (1024 * 1024); + const heapStats = v8.getHeapStatistics(); + const currentMaxOldSpaceSizeMb = Math.floor( + heapStats.heap_size_limit / 1024 / 1024, + ); + cachedMemoryValues = { totalMemoryMB, currentMaxOldSpaceSizeMb }; + } + return cachedMemoryValues; +} + function getNodeMemoryArgs(isDebugMode: boolean): string[] { - const totalMemoryMB = os.totalmem() / (1024 * 1024); - const heapStats = v8.getHeapStatistics(); - const currentMaxOldSpaceSizeMb = Math.floor( - heapStats.heap_size_limit / 1024 / 1024, - ); + const { totalMemoryMB, currentMaxOldSpaceSizeMb } = getMemoryValues(); // Set target to 50% of total memory const targetMaxOldSpaceSizeInMB = Math.floor(totalMemoryMB * 0.5); diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index ea8482a16..27c1c3d5d 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -5,7 +5,7 @@ */ import { useIsScreenReaderEnabled } from 'ink'; -import { useTerminalSize } from './hooks/useTerminalSize.js'; +import { useStableTerminalSize } from './hooks/useStableSize.js'; import { lerp } from '../utils/math.js'; import { useUIState } from './contexts/UIStateContext.js'; import { StreamingContext } from './contexts/StreamingContext.js'; @@ -31,7 +31,7 @@ const getContainerWidth = (terminalWidth: number): string => { export const App = () => { const uiState = useUIState(); const isScreenReaderEnabled = useIsScreenReaderEnabled(); - const { columns } = useTerminalSize(); + const { columns } = useStableTerminalSize(); const containerWidth = getContainerWidth(columns); if (uiState.quittingMessages) { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ecacbda45..dbedc134a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -55,7 +55,7 @@ import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useVimMode } from './contexts/VimModeContext.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js'; -import { useTerminalSize } from './hooks/useTerminalSize.js'; +import { useStableTerminalSize } from './hooks/useStableSize.js'; import { calculatePromptWidths } from './components/InputPrompt.js'; import { useStdin, useStdout } from 'ink'; import ansiEscapes from 'ansi-escapes'; @@ -97,6 +97,7 @@ import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js'; import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js'; import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js'; import { useAttentionNotifications } from './hooks/useAttentionNotifications.js'; +import { ThemeProvider } from './contexts/ThemeContext.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; @@ -199,7 +200,8 @@ export const AppContainer = (props: AppContainerProps) => { const [userMessages, setUserMessages] = useState([]); // Terminal and layout hooks - const { columns: terminalWidth, rows: terminalHeight } = useTerminalSize(); + const { columns: terminalWidth, rows: terminalHeight } = + useStableTerminalSize(); const { stdin, setRawMode } = useStdin(); const { stdout } = useStdout(); @@ -269,8 +271,16 @@ export const AppContainer = (props: AppContainerProps) => { calculatePromptWidths(terminalWidth); return { inputWidth, suggestionsWidth }; }, [terminalWidth]); - const mainAreaWidth = Math.floor(terminalWidth * 0.9); - const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100); + + // Memoize main area width and static area height to prevent recalculation + const mainAreaWidth = useMemo( + () => Math.floor(terminalWidth * 0.9), + [terminalWidth], + ); + const staticAreaMaxItemHeight = useMemo( + () => Math.max(terminalHeight * 4, 100), + [terminalHeight], + ); const isValidPath = useCallback((filePath: string): boolean => { try { @@ -746,20 +756,31 @@ export const AppContainer = (props: AppContainerProps) => { terminalHeight - controlsHeight - staticExtraHeight - 2, ); - config.setShellExecutionConfig({ - terminalWidth: Math.floor(terminalWidth * SHELL_WIDTH_FRACTION), - terminalHeight: Math.max( - Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), - 1, - ), - pager: settings.merged.tools?.shell?.pager, - showColor: settings.merged.tools?.shell?.showColor, - }); + // Memoize shell execution configuration to avoid unnecessary recalculations + const shellExecutionConfig = useMemo( + () => ({ + terminalWidth: Math.floor(terminalWidth * SHELL_WIDTH_FRACTION), + terminalHeight: Math.max( + Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), + 1, + ), + pager: settings.merged.tools?.shell?.pager, + showColor: settings.merged.tools?.shell?.showColor, + }), + [ + terminalWidth, + availableTerminalHeight, + settings.merged.tools?.shell?.pager, + settings.merged.tools?.shell?.showColor, + ], + ); + + config.setShellExecutionConfig(shellExecutionConfig); const isFocused = useFocus(); useBracketedPaste(); - // Context file names computation + // Context file names computation - memoize to prevent recalculation unless settings change const contextFileNames = useMemo(() => { const fromSettings = settings.merged.context?.fileName; return fromSettings @@ -1153,7 +1174,10 @@ export const AppContainer = (props: AppContainerProps) => { if (config.getDebugMode()) { return consoleMessages; } - return consoleMessages.filter((msg) => msg.type !== 'debug'); + // More efficient filtering by avoiding unnecessary operations when not in debug mode + return consoleMessages.length > 0 + ? consoleMessages.filter((msg) => msg.type !== 'debug') + : []; }, [consoleMessages, config]); // Computed values @@ -1466,7 +1490,9 @@ export const AppContainer = (props: AppContainerProps) => { }} > - + + + diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index 468ec8888..60a54ebeb 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { useCallback, useState } from 'react'; +import { useCallback, useState, useMemo } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js'; @@ -46,33 +46,35 @@ export function ThemeDialog({ string | undefined >(settings.merged.ui?.theme || DEFAULT_THEME.name); - // Generate theme items filtered by selected scope - const customThemes = - selectedScope === SettingScope.User - ? settings.user.settings.ui?.customThemes || {} - : settings.merged.ui?.customThemes || {}; - const builtInThemes = themeManager - .getAvailableThemes() - .filter((theme) => theme.type !== 'custom'); - const customThemeNames = Object.keys(customThemes); - const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); - // Generate theme items - const themeItems = [ - ...builtInThemes.map((theme) => ({ - label: theme.name, - value: theme.name, - themeNameDisplay: theme.name, - themeTypeDisplay: capitalize(theme.type), - key: theme.name, - })), - ...customThemeNames.map((name) => ({ - label: name, - value: name, - themeNameDisplay: name, - themeTypeDisplay: 'Custom', - key: name, - })), - ]; + // Memoize theme items to prevent unnecessary recalculations on each render + const themeItems = useMemo(() => { + const customThemes = + selectedScope === SettingScope.User + ? settings.user.settings.ui?.customThemes || {} + : settings.merged.ui?.customThemes || {}; + const builtInThemes = themeManager + .getAvailableThemes() + .filter((theme) => theme.type !== 'custom'); + const customThemeNames = Object.keys(customThemes); + const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + // Generate theme items + return [ + ...builtInThemes.map((theme) => ({ + label: theme.name, + value: theme.name, + themeNameDisplay: theme.name, + themeTypeDisplay: capitalize(theme.type), + key: theme.name, + })), + ...customThemeNames.map((name) => ({ + label: name, + value: name, + themeNameDisplay: name, + themeTypeDisplay: 'Custom', + key: name, + })), + ]; + }, [selectedScope, settings]); // Find the index of the selected theme, but only if it exists in the list const initialThemeIndex = themeItems.findIndex( @@ -125,63 +127,75 @@ export function ThemeDialog({ settings, ); - // Constants for calculating preview pane layout. - // These values are based on the JSX structure below. - const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55; - // A safety margin to prevent text from touching the border. - // This is a complete hack unrelated to the 0.9 used in App.tsx - const PREVIEW_PANE_WIDTH_SAFETY_MARGIN = 0.9; - // Combined horizontal padding from the dialog and preview pane. - const TOTAL_HORIZONTAL_PADDING = 4; - const colorizeCodeWidth = Math.max( - Math.floor( - (terminalWidth - TOTAL_HORIZONTAL_PADDING) * - PREVIEW_PANE_WIDTH_PERCENTAGE * - PREVIEW_PANE_WIDTH_SAFETY_MARGIN, - ), - 1, - ); + // Memoize layout calculations to prevent unnecessary recalculations on each render + const { colorizeCodeWidth, includePadding, codeBlockHeight, diffHeight } = + useMemo(() => { + // Constants for calculating preview pane layout. + // These values are based on the JSX structure below. + const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55; + // A safety margin to prevent text from touching the border. + // This is a complete hack unrelated to the 0.9 used in App.tsx + const PREVIEW_PANE_WIDTH_SAFETY_MARGIN = 0.9; + // Combined horizontal padding from the dialog and preview pane. + const TOTAL_HORIZONTAL_PADDING = 4; + const colorizeCodeWidth = Math.max( + Math.floor( + (terminalWidth - TOTAL_HORIZONTAL_PADDING) * + PREVIEW_PANE_WIDTH_PERCENTAGE * + PREVIEW_PANE_WIDTH_SAFETY_MARGIN, + ), + 1, + ); - const DIALOG_PADDING = 2; - const selectThemeHeight = themeItems.length + 1; - const TAB_TO_SELECT_HEIGHT = 2; - availableTerminalHeight = availableTerminalHeight ?? Number.MAX_SAFE_INTEGER; - availableTerminalHeight -= 2; // Top and bottom borders. - availableTerminalHeight -= TAB_TO_SELECT_HEIGHT; + const DIALOG_PADDING = 2; + const selectThemeHeight = themeItems.length + 1; + const TAB_TO_SELECT_HEIGHT = 2; + let localAvailableTerminalHeight = + availableTerminalHeight ?? Number.MAX_SAFE_INTEGER; + localAvailableTerminalHeight -= 2; // Top and bottom borders. + localAvailableTerminalHeight -= TAB_TO_SELECT_HEIGHT; - let totalLeftHandSideHeight = DIALOG_PADDING + selectThemeHeight; + let totalLeftHandSideHeight = DIALOG_PADDING + selectThemeHeight; - let includePadding = true; + let includePadding = true; - // Remove content from the LHS that can be omitted if it exceeds the available height. - if (totalLeftHandSideHeight > availableTerminalHeight) { - includePadding = false; - totalLeftHandSideHeight -= DIALOG_PADDING; - } + // Remove content from the LHS that can be omitted if it exceeds the available height. + if (totalLeftHandSideHeight > localAvailableTerminalHeight) { + includePadding = false; + totalLeftHandSideHeight -= DIALOG_PADDING; + } - // Vertical space taken by elements other than the two code blocks in the preview pane. - // Includes "Preview" title, borders, and margin between blocks. - const PREVIEW_PANE_FIXED_VERTICAL_SPACE = 8; + // Vertical space taken by elements other than the two code blocks in the preview pane. + // Includes "Preview" title, borders, and margin between blocks. + const PREVIEW_PANE_FIXED_VERTICAL_SPACE = 8; - // The right column doesn't need to ever be shorter than the left column. - availableTerminalHeight = Math.max( - availableTerminalHeight, - totalLeftHandSideHeight, - ); - const availableTerminalHeightCodeBlock = - availableTerminalHeight - - PREVIEW_PANE_FIXED_VERTICAL_SPACE - - (includePadding ? 2 : 0) * 2; + // The right column doesn't need to ever be shorter than the left column. + localAvailableTerminalHeight = Math.max( + localAvailableTerminalHeight, + totalLeftHandSideHeight, + ); + const availableTerminalHeightCodeBlock = + localAvailableTerminalHeight - + PREVIEW_PANE_FIXED_VERTICAL_SPACE - + (includePadding ? 2 : 0) * 2; - // Subtract margin between code blocks from available height. - const availableHeightForPanes = Math.max( - 0, - availableTerminalHeightCodeBlock - 1, - ); + // Subtract margin between code blocks from available height. + const availableHeightForPanes = Math.max( + 0, + availableTerminalHeightCodeBlock - 1, + ); + + // The code block is slightly longer than the diff, so give it more space. + const codeBlockHeight = Math.ceil(availableHeightForPanes * 0.6); + const diffHeight = Math.floor(availableHeightForPanes * 0.4); - // The code block is slightly longer than the diff, so give it more space. - const codeBlockHeight = Math.ceil(availableHeightForPanes * 0.6); - const diffHeight = Math.floor(availableHeightForPanes * 0.4); + return { + colorizeCodeWidth, + includePadding, + codeBlockHeight, + diffHeight, + }; + }, [terminalWidth, availableTerminalHeight, themeItems.length]); return ( void; // Function to manually trigger a theme update +} + +// Create the context with a default value +const ThemeContext = createContext(undefined); + +interface ThemeProviderProps { + children: ReactNode; +} + +// ThemeProvider component +export const ThemeProvider = ({ children }: ThemeProviderProps) => { + const [activeTheme, setActiveTheme] = useState(themeManager.getActiveTheme()); + + // Function to update the theme state + const updateTheme = () => { + const newTheme = themeManager.getActiveTheme(); + setActiveTheme(newTheme); + }; + + // Effect to update theme when themeManager changes + useEffect(() => { + // Update immediately on mount + updateTheme(); + + // Set up a listener for theme changes + const handleThemeChange = () => { + // Update theme when it changes + updateTheme(); + }; + + // Add listener for theme changes + themeManager.on('themeChanged', handleThemeChange); + + // Cleanup listener on unmount + return () => { + themeManager.off('themeChanged', handleThemeChange); + }; + }, []); // Only run once on mount + + // Create the context value + const contextValue: ThemeContextType = { + theme: activeTheme.semanticColors, + activeTheme, + updateTheme, + }; + + return ( + + {children} + + ); +}; + +// Custom hook to use the theme context +export const useTheme = () => { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +}; diff --git a/packages/cli/src/ui/hooks/useStableSize.ts b/packages/cli/src/ui/hooks/useStableSize.ts new file mode 100644 index 000000000..8ee1bb725 --- /dev/null +++ b/packages/cli/src/ui/hooks/useStableSize.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect } from 'react'; +import { useTerminalSize } from './useTerminalSize.js'; + +/** + * Stable terminal size hook that prevents unnecessary re-renders when terminal size + * changes only slightly, which can happen with some terminals + */ +export function useStableTerminalSize(minChangeThreshold: number = 2) { + const { columns: rawColumns, rows: rawRows } = useTerminalSize(); + const [stableSize, setStableSize] = useState({ + columns: rawColumns, + rows: rawRows, + }); + + useEffect(() => { + // Only update if the change is significant enough to warrant a re-render + const colDiff = Math.abs(rawColumns - stableSize.columns); + const rowDiff = Math.abs(rawRows - stableSize.rows); + + if (colDiff >= minChangeThreshold || rowDiff >= minChangeThreshold) { + setStableSize({ + columns: rawColumns, + rows: rawRows, + }); + } + }, [ + rawColumns, + rawRows, + stableSize.columns, + stableSize.rows, + minChangeThreshold, + ]); + + return stableSize; +} diff --git a/packages/cli/src/ui/hooks/useThemeCommand.ts b/packages/cli/src/ui/hooks/useThemeCommand.ts index 9c534538c..3b46f68fb 100644 --- a/packages/cli/src/ui/hooks/useThemeCommand.ts +++ b/packages/cli/src/ui/hooks/useThemeCommand.ts @@ -80,9 +80,13 @@ export const useThemeCommand = ( return; } loadedSettings.setValue(scope, 'ui.theme', themeName); // Update the merged settings + + // Only reload custom themes if they exist in the merged settings if (loadedSettings.merged.ui?.customThemes) { - themeManager.loadCustomThemes(loadedSettings.merged.ui?.customThemes); + themeManager.loadCustomThemes(loadedSettings.merged.ui.customThemes); } + + // Apply the current theme from merged settings (not just the themeName parameter) applyTheme(loadedSettings.merged.ui?.theme); // Apply the current theme setThemeError(null); } finally { diff --git a/packages/cli/src/ui/semantic-colors.ts b/packages/cli/src/ui/semantic-colors.ts index 88e75833f..0e6ffe7c0 100644 --- a/packages/cli/src/ui/semantic-colors.ts +++ b/packages/cli/src/ui/semantic-colors.ts @@ -4,9 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { themeManager } from './themes/theme-manager.js'; import type { SemanticColors } from './themes/semantic-tokens.js'; +// This file is deprecated. Use the useTheme hook from ThemeContext instead. +// The static exports will remain for backward compatibility during migration. +// To use the dynamic theme that updates when the theme changes, use the useTheme hook. + +import { themeManager } from './themes/theme-manager.js'; + export const theme: SemanticColors = { get text() { return themeManager.getSemanticColors().text; diff --git a/packages/cli/src/ui/themes/theme-manager.ts b/packages/cli/src/ui/themes/theme-manager.ts index 7daa6a290..f7ade7b1a 100644 --- a/packages/cli/src/ui/themes/theme-manager.ts +++ b/packages/cli/src/ui/themes/theme-manager.ts @@ -27,6 +27,7 @@ import { ANSI } from './ansi.js'; import { ANSILight } from './ansi-light.js'; import { NoColorTheme } from './no-color.js'; import process from 'node:process'; +import { EventEmitter } from 'node:events'; export interface ThemeDisplay { name: string; @@ -36,12 +37,15 @@ export interface ThemeDisplay { export const DEFAULT_THEME: Theme = QwenDark; -class ThemeManager { +class ThemeManager extends EventEmitter { private readonly availableThemes: Theme[]; + private readonly themeNameMap: Map; private activeTheme: Theme; private customThemes: Map = new Map(); + private readonly fileThemeCache: Map = new Map(); constructor() { + super(); this.availableThemes = [ AyuDark, AyuLight, @@ -59,6 +63,11 @@ class ThemeManager { ANSI, ANSILight, ]; + // Create a map for O(1) theme lookup by name + this.themeNameMap = new Map(); + for (const theme of this.availableThemes) { + this.themeNameMap.set(theme.name, theme); + } this.activeTheme = DEFAULT_THEME; } @@ -70,9 +79,15 @@ class ThemeManager { this.customThemes.clear(); if (!customThemesSettings) { + // If no custom themes are provided, ensure active theme isn't a custom one + if (this.activeTheme.type === 'custom') { + this.activeTheme = DEFAULT_THEME; + } return; } + // Process all custom themes first to avoid multiple setActiveTheme calls + const validCustomThemes = new Map(); for (const [name, customThemeConfig] of Object.entries( customThemesSettings, )) { @@ -90,7 +105,7 @@ class ThemeManager { try { const theme = createCustomTheme(themeWithDefaults); - this.customThemes.set(name, theme); + validCustomThemes.set(name, theme); } catch (error) { console.warn(`Failed to load custom theme "${name}":`, error); } @@ -98,6 +113,10 @@ class ThemeManager { console.warn(`Invalid custom theme "${name}": ${validation.error}`); } } + + // Set the valid custom themes after processing all of them + this.customThemes = validCustomThemes; + // If the current active theme is a custom theme, keep it if still valid if ( this.activeTheme && @@ -118,10 +137,38 @@ class ThemeManager { if (!theme) { return false; } + + const oldThemeName = this.activeTheme?.name; this.activeTheme = theme; + + // Emit theme change event + this.emit('themeChanged', theme, oldThemeName); + return true; } + /** + * Adds a listener for theme changes. + * @param eventName The name of the event ('themeChanged'). + * @param listener The callback to execute when the theme changes. + */ + onThemeChange( + listener: (newTheme: Theme, oldThemeName: string | undefined) => void, + ): void { + this.on('themeChanged', listener); + } + + /** + * Removes a listener for theme changes. + * @param eventName The name of the event ('themeChanged'). + * @param listener The callback to remove. + */ + offThemeChange( + listener: (newTheme: Theme, oldThemeName: string | undefined) => void, + ): void { + this.off('themeChanged', listener); + } + /** * Gets the currently active theme. * @returns The active theme. @@ -138,8 +185,11 @@ class ThemeManager { const isCustom = [...this.customThemes.values()].includes( this.activeTheme, ); + const isFromFile = [...this.fileThemeCache.values()].includes( + this.activeTheme, + ); - if (isBuiltIn || isCustom) { + if (isBuiltIn || isCustom || isFromFile) { return this.activeTheme; } } @@ -178,11 +228,24 @@ class ThemeManager { * Returns a list of available theme names. */ getAvailableThemes(): ThemeDisplay[] { - const builtInThemes = this.availableThemes.map((theme) => ({ - name: theme.name, - type: theme.type, - isCustom: false, - })); + // Create theme displays from the cached map for better performance + const builtInThemes: ThemeDisplay[] = []; + const qwenThemes: ThemeDisplay[] = []; + + // Efficiently separate Qwen themes and other built-in themes + for (const theme of this.availableThemes) { + const themeDisplay: ThemeDisplay = { + name: theme.name, + type: theme.type, + isCustom: false, + }; + + if (theme.name === QwenLight.name || theme.name === QwenDark.name) { + qwenThemes.push(themeDisplay); + } else { + builtInThemes.push(themeDisplay); + } + } const customThemes = Array.from(this.customThemes.values()).map( (theme) => ({ @@ -192,16 +255,8 @@ class ThemeManager { }), ); - // Separate Qwen themes - const qwenThemes = builtInThemes.filter( - (theme) => theme.name === QwenLight.name || theme.name === QwenDark.name, - ); - const otherBuiltInThemes = builtInThemes.filter( - (theme) => theme.name !== QwenLight.name && theme.name !== QwenDark.name, - ); - // Sort other themes by type and then name - const sortedOtherThemes = [...otherBuiltInThemes, ...customThemes].sort( + const sortedOtherThemes = [...builtInThemes, ...customThemes].sort( (a, b) => { const typeOrder = (type: ThemeType): number => { switch (type) { @@ -252,9 +307,9 @@ class ThemeManager { // realpathSync resolves the path and throws if it doesn't exist. const canonicalPath = fs.realpathSync(path.resolve(themePath)); - // 1. Check cache using the canonical path. - if (this.customThemes.has(canonicalPath)) { - return this.customThemes.get(canonicalPath); + // 1. Check file theme cache using the canonical path. + if (this.fileThemeCache.has(canonicalPath)) { + return this.fileThemeCache.get(canonicalPath); } // 2. Perform security check. @@ -292,7 +347,7 @@ class ThemeManager { }; const theme = createCustomTheme(themeWithDefaults); - this.customThemes.set(canonicalPath, theme); // Cache by canonical path + this.fileThemeCache.set(canonicalPath, theme); // Cache by canonical path return theme; } catch (error) { // Any error in the process (file not found, bad JSON, etc.) is caught here. @@ -311,27 +366,34 @@ class ThemeManager { return DEFAULT_THEME; } - // First check built-in themes - const builtInTheme = this.availableThemes.find( - (theme) => theme.name === themeName, - ); + // First check built-in themes using the cached map for O(1) lookup + const builtInTheme = this.themeNameMap.get(themeName); if (builtInTheme) { return builtInTheme; } - // Then check custom themes that have been loaded from settings, or file paths - if (this.isPath(themeName)) { - return this.loadThemeFromFile(themeName); - } - + // Then check custom themes that have been loaded from settings if (this.customThemes.has(themeName)) { return this.customThemes.get(themeName); } + // Finally check file paths + if (this.isPath(themeName)) { + return this.loadThemeFromFile(themeName); + } + // If it's not a built-in, not in cache, and not a valid file path, // it's not a valid theme. return undefined; } + + /** + * Clears the file theme cache to free up memory. + * This is useful when reloading many theme files to prevent memory bloat. + */ + clearFileThemeCache(): void { + this.fileThemeCache.clear(); + } } // Export an instance of the ThemeManager diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 2218832ed..bb78d2922 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -172,7 +172,7 @@ export async function createContentGenerator( throw new Error('OpenAI API key is required'); } - // Import OpenAIContentGenerator dynamically to avoid circular dependencies + // Import OpenAIContentGenerator dynamically to avoid circular dependencies and reduce initial load const { createOpenAIContentGenerator } = await import( './openaiContentGenerator/index.js' ); @@ -182,13 +182,14 @@ export async function createContentGenerator( } if (config.authType === AuthType.QWEN_OAUTH) { - // Import required classes dynamically - const { getQwenOAuthClient: getQwenOauthClient } = await import( - '../qwen/qwenOAuth2.js' - ); - const { QwenContentGenerator } = await import( - '../qwen/qwenContentGenerator.js' - ); + // Import required classes dynamically to reduce initial bundle size + const [ + { getQwenOAuthClient: getQwenOauthClient }, + { QwenContentGenerator }, + ] = await Promise.all([ + import('../qwen/qwenOAuth2.js'), + import('../qwen/qwenContentGenerator.js'), + ]); try { // Get the Qwen OAuth client (now includes integrated token management) diff --git a/packages/core/src/core/tokenLimits.ts b/packages/core/src/core/tokenLimits.ts index 914715802..01c48373f 100644 --- a/packages/core/src/core/tokenLimits.ts +++ b/packages/core/src/core/tokenLimits.ts @@ -217,6 +217,9 @@ const OUTPUT_PATTERNS: Array<[RegExp, TokenCount]> = [ [/^deepseek-reasoner$/, LIMITS['64k']], ]; +// Cache for token limits to reduce pattern matching overhead +const tokenLimitCache = new Map(); + /** * Return the token limit for a model string based on the specified type. * @@ -232,6 +235,15 @@ export function tokenLimit( model: Model, type: TokenLimitType = 'input', ): TokenCount { + // Create cache key combining model and type + const cacheKey = `${model}#${type}`; + + // Check if result is already cached + const cached = tokenLimitCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + const norm = normalize(model); // Choose the appropriate patterns based on token type @@ -239,10 +251,22 @@ export function tokenLimit( for (const [regex, limit] of patterns) { if (regex.test(norm)) { + // Cache the result before returning + tokenLimitCache.set(cacheKey, limit); return limit; } } // Return appropriate default based on token type - return type === 'output' ? DEFAULT_OUTPUT_TOKEN_LIMIT : DEFAULT_TOKEN_LIMIT; + const defaultLimit = + type === 'output' ? DEFAULT_OUTPUT_TOKEN_LIMIT : DEFAULT_TOKEN_LIMIT; + tokenLimitCache.set(cacheKey, defaultLimit); + return defaultLimit; +} + +/** + * Clears the token limit cache, useful for testing or when model configurations change. + */ +export function clearTokenLimitCache(): void { + tokenLimitCache.clear(); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 883fb1141..4212480f6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -58,6 +58,9 @@ export * from './utils/subagentGenerator.js'; export * from './utils/projectSummary.js'; export * from './utils/promptIdContext.js'; export * from './utils/thoughtUtils.js'; +export * from './utils/cached-file-system.js'; +export * from './utils/request-deduplicator.js'; +export * from './utils/general-cache.js'; // Export services export * from './services/fileDiscoveryService.js'; diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts index 33ea33399..05020778f 100644 --- a/packages/core/src/tools/read-many-files.ts +++ b/packages/core/src/tools/read-many-files.ts @@ -282,77 +282,85 @@ ${finalExclusionPatternsForDescription ? Math.floor(truncateToolOutputLines / Math.max(1, sortedFiles.length)) : undefined; - const fileProcessingPromises = sortedFiles.map( - async (filePath): Promise => { - try { - const relativePathForDisplay = path - .relative(this.config.getTargetDir(), filePath) - .replace(/\\/g, '/'); - - const fileType = await detectFileType(filePath); - - if (fileType === 'image' || fileType === 'pdf') { - const fileExtension = path.extname(filePath).toLowerCase(); - const fileNameWithoutExtension = path.basename( + // Process files with a concurrency limit to prevent overwhelming the system + const CONCURRENCY_LIMIT = 10; + const results: Array> = []; + + for (let i = 0; i < sortedFiles.length; i += CONCURRENCY_LIMIT) { + const batch = sortedFiles.slice(i, i + CONCURRENCY_LIMIT); + const batchPromises = batch.map( + async (filePath): Promise => { + try { + const relativePathForDisplay = path + .relative(this.config.getTargetDir(), filePath) + .replace(/\\/g, '/'); + + const fileType = await detectFileType(filePath); + + if (fileType === 'image' || fileType === 'pdf') { + const fileExtension = path.extname(filePath).toLowerCase(); + const fileNameWithoutExtension = path.basename( + filePath, + fileExtension, + ); + const requestedExplicitly = inputPatterns.some( + (pattern: string) => + pattern.toLowerCase().includes(fileExtension) || + pattern.includes(fileNameWithoutExtension), + ); + + if (!requestedExplicitly) { + return { + success: false, + filePath, + relativePathForDisplay, + reason: + 'asset file (image/pdf) was not explicitly requested by name or extension', + }; + } + } + + // Use processSingleFileContent for all file types now + const fileReadResult = await processSingleFileContent( filePath, - fileExtension, - ); - const requestedExplicitly = inputPatterns.some( - (pattern: string) => - pattern.toLowerCase().includes(fileExtension) || - pattern.includes(fileNameWithoutExtension), + this.config, + 0, + file_line_limit, ); - if (!requestedExplicitly) { + if (fileReadResult.error) { return { success: false, filePath, relativePathForDisplay, - reason: - 'asset file (image/pdf) was not explicitly requested by name or extension', + reason: `Read error: ${fileReadResult.error}`, }; } - } - // Use processSingleFileContent for all file types now - const fileReadResult = await processSingleFileContent( - filePath, - this.config, - 0, - file_line_limit, - ); + return { + success: true, + filePath, + relativePathForDisplay, + fileReadResult, + }; + } catch (error) { + const relativePathForDisplay = path + .relative(this.config.getTargetDir(), filePath) + .replace(/\\/g, '/'); - if (fileReadResult.error) { return { success: false, filePath, relativePathForDisplay, - reason: `Read error: ${fileReadResult.error}`, + reason: `Unexpected error: ${error instanceof Error ? error.message : String(error)}`, }; } + }, + ); - return { - success: true, - filePath, - relativePathForDisplay, - fileReadResult, - }; - } catch (error) { - const relativePathForDisplay = path - .relative(this.config.getTargetDir(), filePath) - .replace(/\\/g, '/'); - - return { - success: false, - filePath, - relativePathForDisplay, - reason: `Unexpected error: ${error instanceof Error ? error.message : String(error)}`, - }; - } - }, - ); - - const results = await Promise.allSettled(fileProcessingPromises); + const batchResults = await Promise.allSettled(batchPromises); + results.push(...batchResults); + } for (const result of results) { if (result.status === 'fulfilled') { diff --git a/packages/core/src/utils/cached-file-system.ts b/packages/core/src/utils/cached-file-system.ts new file mode 100644 index 000000000..bfb5e8bbb --- /dev/null +++ b/packages/core/src/utils/cached-file-system.ts @@ -0,0 +1,221 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { Stats } from 'node:fs'; + +/** + * Cache for file system operations to reduce unnecessary I/O calls. + */ +export class CachedFileSystem { + private readonly fileStatCache: Map< + string, + { stats: Stats; timestamp: number } + > = new Map(); + private readonly fileContentCache: Map< + string, + { content: string; timestamp: number } + > = new Map(); + private readonly directoryCache: Map< + string, + { entries: string[]; timestamp: number } + > = new Map(); + private readonly cacheTimeoutMs: number; + + constructor(cacheTimeoutMs: number = 5000) { + // 5 second default cache timeout + this.cacheTimeoutMs = cacheTimeoutMs; + } + + /** + * Checks if an item is expired based on timestamp + */ + private isExpired(timestamp: number): boolean { + return Date.now() - timestamp >= this.cacheTimeoutMs; + } + + /** + * Gets file stats with caching to reduce fs calls. + * @param filePath Path to the file or directory + * @returns Stats object or null if file doesn't exist + */ + async stat(filePath: string): Promise { + const cacheKey = path.resolve(filePath); + const now = Date.now(); + + const cached = this.fileStatCache.get(cacheKey); + if (cached && !this.isExpired(cached.timestamp)) { + return cached.stats; + } + + // Clean expired entry if present + if (cached) { + this.fileStatCache.delete(cacheKey); + } + + try { + const stats = await fs.stat(filePath); + this.fileStatCache.set(cacheKey, { stats, timestamp: now }); + return stats; + } catch (error) { + // Cache non-existence to avoid repeated checks + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + this.fileStatCache.set(cacheKey, { + stats: null as unknown as Stats, + timestamp: now, + }); + return null; + } + throw error; + } + } + + /** + * Checks if a file or directory exists with caching. + * @param filePath Path to check + * @returns Boolean indicating existence + */ + async exists(filePath: string): Promise { + const stats = await this.stat(filePath); + return stats !== null; + } + + /** + * Checks if the given path is a directory with caching. + * @param filePath Path to check + * @returns Boolean indicating if it's a directory + */ + async isDirectory(filePath: string): Promise { + const stats = await this.stat(filePath); + return stats !== null && stats.isDirectory(); + } + + /** + * Checks if the given path is a file with caching. + * @param filePath Path to check + * @returns Boolean indicating if it's a file + */ + async isFile(filePath: string): Promise { + const stats = await this.stat(filePath); + return stats !== null && stats.isFile(); + } + + /** + * Reads file content with caching to prevent repeated reads. + * @param filePath Path to the file + * @param encoding File encoding (default: 'utf-8') + * @returns File content as string + */ + async readFile( + filePath: string, + encoding: BufferEncoding = 'utf-8', + ): Promise { + const cacheKey = path.resolve(filePath); + const now = Date.now(); + + const cached = this.fileContentCache.get(cacheKey); + if (cached && !this.isExpired(cached.timestamp)) { + return cached.content; + } + + // Clean expired entry if present + if (cached) { + this.fileContentCache.delete(cacheKey); + } + + const content = await fs.readFile(filePath, encoding); + this.fileContentCache.set(cacheKey, { content, timestamp: now }); + return content; + } + + /** + * Reads directory contents with caching to reduce repeated scans. + * @param dirPath Path to the directory + * @returns Array of directory entries + */ + async readDir(dirPath: string): Promise { + const cacheKey = path.resolve(dirPath); + const now = Date.now(); + + const cached = this.directoryCache.get(cacheKey); + if (cached && !this.isExpired(cached.timestamp)) { + return [...cached.entries]; // Return a copy to prevent external modifications + } + + // Clean expired entry if present + if (cached) { + this.directoryCache.delete(cacheKey); + } + + const entries = await fs.readdir(dirPath); + this.directoryCache.set(cacheKey, { entries, timestamp: now }); + return entries; + } + + /** + * Clears all caches or a specific cache type. + */ + clearCache(type?: 'stats' | 'content' | 'directories' | 'all'): void { + if (type === 'stats' || type === undefined || type === 'all') { + this.fileStatCache.clear(); + } + if (type === 'content' || type === undefined || type === 'all') { + this.fileContentCache.clear(); + } + if (type === 'directories' || type === undefined || type === 'all') { + this.directoryCache.clear(); + } + } + + /** + * Invalidates a specific path in all caches. + * @param filePath Path to invalidate + */ + invalidatePath(filePath: string): void { + const resolvedPath = path.resolve(filePath); + this.fileStatCache.delete(resolvedPath); + this.fileContentCache.delete(resolvedPath); + this.directoryCache.delete(resolvedPath); + + // Also invalidate parent directory cache + const parentDir = path.dirname(resolvedPath); + if (parentDir !== resolvedPath) { + // Avoid infinite recursion if path is root + this.directoryCache.delete(parentDir); + } + } + + /** + * Cleans expired entries from cache + */ + private cleanExpired(cache: Map): void { + for (const [key, value] of cache.entries()) { + if (this.isExpired(value.timestamp)) { + cache.delete(key); + } + } + } + + /** + * Gets current cache statistics for debugging/monitoring. + */ + getCacheStats(): { stats: number; content: number; directories: number } { + // Clean expired entries before reporting stats + this.cleanExpired(this.fileStatCache); + this.cleanExpired(this.fileContentCache); + this.cleanExpired(this.directoryCache); + + return { + stats: this.fileStatCache.size, + content: this.fileContentCache.size, + directories: this.directoryCache.size, + }; + } +} + +// Create a global instance to share across the application +export const cachedFileSystem = new CachedFileSystem(); diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index 940e9794d..6bda63120 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -12,6 +12,7 @@ import mime from 'mime/lite'; import { ToolErrorType } from '../tools/tool-error.js'; import { BINARY_EXTENSIONS } from './ignorePatterns.js'; import type { Config } from '../config/config.js'; +import { cachedFileSystem } from './cached-file-system.js'; // Default values for encoding and separator format export const DEFAULT_ENCODING: BufferEncoding = 'utf-8'; @@ -119,14 +120,15 @@ function decodeUTF32(buf: Buffer, littleEndian: boolean): string { * Falls back to utf8 when no BOM is present. */ export async function readFileWithEncoding(filePath: string): Promise { - // Read the file once; detect BOM and decode from the single buffer. + // For BOM detection, we need to read the raw bytes first + // Use fs directly for this first read to detect BOM const full = await fs.promises.readFile(filePath); if (full.length === 0) return ''; const bom = detectBOM(full); if (!bom) { - // No BOM → treat as UTF‑8 - return full.toString('utf8'); + // No BOM → treat as UTF‑8, can use cached content for subsequent reads + return await cachedFileSystem.readFile(filePath, 'utf8'); } // Strip BOM and decode per encoding @@ -191,49 +193,46 @@ export function isWithinRoot( * For non-BOM files, retain the existing null-byte and non-printable ratio checks. */ export async function isBinaryFile(filePath: string): Promise { - let fh: fs.promises.FileHandle | null = null; try { - fh = await fs.promises.open(filePath, 'r'); - const stats = await fh.stat(); - const fileSize = stats.size; - if (fileSize === 0) return false; // empty is not binary + // Use the cached file system to get stats, which may already be cached + const stats = await cachedFileSystem.stat(filePath); + if (!stats) { + // If file doesn't exist, return false as per test expectation + return false; + } + if (stats.size === 0) return false; // empty is not binary // Sample up to 4KB from the head (previous behavior) - const sampleSize = Math.min(4096, fileSize); - const buf = Buffer.alloc(sampleSize); - const { bytesRead } = await fh.read(buf, 0, sampleSize, 0); - if (bytesRead === 0) return false; - - // BOM → text (avoid false positives for UTF‑16/32 with nulls) - const bom = detectBOM(buf.subarray(0, Math.min(4, bytesRead))); - if (bom) return false; - - let nonPrintableCount = 0; - for (let i = 0; i < bytesRead; i++) { - if (buf[i] === 0) return true; // strong indicator of binary when no BOM - if (buf[i] < 9 || (buf[i] > 13 && buf[i] < 32)) { - nonPrintableCount++; + const sampleSize = Math.min(4096, stats.size); + const fd = await fs.promises.open(filePath, 'r'); + try { + const buf = Buffer.alloc(sampleSize); + const { bytesRead } = await fd.read(buf, 0, sampleSize, 0); + if (bytesRead === 0) return false; + + // BOM → text (avoid false positives for UTF‑16/32 with nulls) + const bom = detectBOM(buf.subarray(0, Math.min(4, bytesRead))); + if (bom) return false; + + let nonPrintableCount = 0; + for (let i = 0; i < bytesRead; i++) { + if (buf[i] === 0) return true; // strong indicator of binary when no BOM + if (buf[i] < 9 || (buf[i] > 13 && buf[i] < 32)) { + nonPrintableCount++; + } } + // If >30% non-printable characters, consider it binary + return nonPrintableCount / bytesRead > 0.3; + } finally { + await fd.close(); } - // If >30% non-printable characters, consider it binary - return nonPrintableCount / bytesRead > 0.3; } catch (error) { console.warn( `Failed to check if file is binary: ${filePath}`, error instanceof Error ? error.message : String(error), ); + // If file access fails (e.g., ENOENT), return false as per test expectation return false; - } finally { - if (fh) { - try { - await fh.close(); - } catch (closeError) { - console.warn( - `Failed to close file handle for: ${filePath}`, - closeError instanceof Error ? closeError.message : String(closeError), - ); - } - } } } @@ -250,7 +249,7 @@ export async function detectFileType( // The mimetype for various TypeScript extensions (ts, mts, cts, tsx) can be // MPEG transport stream (a video format), but we want to assume these are // TypeScript files instead. - if (['.ts', '.mts', '.cts'].includes(ext)) { + if (['.ts', '.mts', '.cts', '.tsx'].includes(ext)) { return 'text'; } @@ -314,9 +313,13 @@ export async function processSingleFileContent( limit?: number, ): Promise { const rootDirectory = config.getTargetDir(); + let stats; + try { - if (!fs.existsSync(filePath)) { - // Sync check is acceptable before async read + // Use cached file system for stats to avoid repeated fs calls + stats = await cachedFileSystem.stat(filePath); + + if (!stats) { return { llmContent: 'Could not read file because no file was found at the specified path.', @@ -325,7 +328,7 @@ export async function processSingleFileContent( errorType: ToolErrorType.FILE_NOT_FOUND, }; } - const stats = await fs.promises.stat(filePath); + if (stats.isDirectory()) { return { llmContent: @@ -375,7 +378,7 @@ export async function processSingleFileContent( case 'text': { // Use BOM-aware reader to avoid leaving a BOM character in content and to support UTF-16/32 transparently const content = await readFileWithEncoding(filePath); - const lines = content.split('\n').map((line) => line.trimEnd()); + const lines = content.split('\n'); const originalLineCount = lines.length; const startLine = offset || 0; @@ -399,12 +402,13 @@ export async function processSingleFileContent( let currentLength = 0; for (const line of selectedLines) { + const lineContent = line.trimEnd(); const sep = linesIncluded > 0 ? 1 : 0; // newline separator linesIncluded++; - const projectedLength = currentLength + line.length + sep; + const projectedLength = currentLength + lineContent.length + sep; if (projectedLength <= configCharLimit) { - formattedLines.push(line); + formattedLines.push(lineContent); currentLength = projectedLength; } else { // Truncate the current line to fit @@ -413,7 +417,7 @@ export async function processSingleFileContent( 10, ); formattedLines.push( - line.substring(0, remaining) + '... [truncated]', + lineContent.substring(0, remaining) + '... [truncated]', ); contentLengthTruncated = true; break; @@ -422,8 +426,8 @@ export async function processSingleFileContent( llmContent = formattedLines.join('\n'); } else { - // No character limit, use all selected lines - llmContent = selectedLines.join('\n'); + // No character limit, use all selected lines with trimming + llmContent = selectedLines.map((line) => line.trimEnd()).join('\n'); linesIncluded = selectedLines.length; } diff --git a/packages/core/src/utils/general-cache.ts b/packages/core/src/utils/general-cache.ts new file mode 100644 index 000000000..5c3f44430 --- /dev/null +++ b/packages/core/src/utils/general-cache.ts @@ -0,0 +1,152 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * General purpose cache with TTL (Time To Live) support + */ +export class GeneralCache { + private readonly cache = new Map(); + + constructor( + private readonly defaultTtlMs: number = 5 * 60 * 1000, // 5 minutes default + ) {} + + /** + * Get a value from the cache + * @param key Cache key + * @returns Value if found and not expired, undefined otherwise + */ + get(key: string): T | undefined { + const item = this.cache.get(key); + if (!item) { + return undefined; + } + + if (Date.now() > item.expiry) { + // Remove expired item + this.cache.delete(key); + return undefined; + } + + return item.value; + } + + /** + * Set a value in the cache + * @param key Cache key + * @param value Value to cache + * @param ttlMs Time to live in milliseconds (optional, uses default if not provided) + */ + set(key: string, value: T, ttlMs?: number): void { + const expiry = Date.now() + (ttlMs ?? this.defaultTtlMs); + this.cache.set(key, { value, expiry }); + } + + /** + * Check if a key exists in the cache and is not expired + * @param key Cache key + * @returns True if key exists and is not expired, false otherwise + */ + has(key: string): boolean { + return this.get(key) !== undefined; + } + + /** + * Delete a key from the cache + * @param key Cache key to delete + */ + delete(key: string): boolean { + return this.cache.delete(key); + } + + /** + * Clear all entries from the cache + */ + clear(): void { + this.cache.clear(); + } + + /** + * Get the number of entries in the cache + */ + size(): number { + // Clean up expired entries while counting + let count = 0; + for (const [key, item] of this.cache.entries()) { + if (Date.now() > item.expiry) { + this.cache.delete(key); + } else { + count++; + } + } + return count; + } + + /** + * Get cache stats + */ + getStats(): { size: number; entries: number } { + const now = Date.now(); + let validEntries = 0; + + for (const item of this.cache.values()) { + if (now <= item.expiry) { + validEntries++; + } + } + + return { size: this.cache.size, entries: validEntries }; + } +} + +/** + * Memoize decorator for caching function results + */ +export function memoize(_ttlMs: number = 5 * 60 * 1000): MethodDecorator { + const ttlMs = _ttlMs; + const cache = new GeneralCache(); + + return function ( + target: unknown, + propertyKey: string | symbol, + descriptor: PropertyDescriptor, + ) { + const originalMethod = descriptor.value; + + descriptor.value = function (...args: unknown[]) { + // Create a key from the method name and arguments + const key = `${String(propertyKey)}:${JSON.stringify(args)}`; + + // Check if result is already cached + const cached = cache.get(key); + if (cached !== undefined) { + return cached; + } + + // Call the original method and cache the result + const result = originalMethod.apply(this, args); + + // If it's a promise, cache the resolved value + if (result instanceof Promise) { + return result.then((resolvedResult) => { + cache.set(key, resolvedResult, ttlMs); + return resolvedResult; + }); + } + + // Cache synchronous result + cache.set(key, result, ttlMs); + return result; + }; + + return descriptor; + }; +} + +// Global cache instances +export const globalCache = new GeneralCache(); +export const modelInfoCache = new GeneralCache(); +export const fileHashCache = new GeneralCache(); diff --git a/packages/core/src/utils/gitUtils.ts b/packages/core/src/utils/gitUtils.ts index 9ac8f1b04..0174b67af 100644 --- a/packages/core/src/utils/gitUtils.ts +++ b/packages/core/src/utils/gitUtils.ts @@ -6,6 +6,10 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import { GeneralCache } from './general-cache.js'; + +// Cache for git repository checks to avoid repeated filesystem traversals +const gitRepoCache = new GeneralCache(30000); // 30 second TTL /** * Checks if a directory is within a git repository @@ -13,14 +17,25 @@ import * as path from 'node:path'; * @returns true if the directory is in a git repository, false otherwise */ export function isGitRepository(directory: string): boolean { + const resolvedDir = path.resolve(directory); + const cacheKey = `isGitRepo:${resolvedDir}`; + + // Check if result is already cached + const cached = gitRepoCache.get(cacheKey); + if (cached !== undefined) { + return cached as boolean; + } + try { - let currentDir = path.resolve(directory); + let currentDir = resolvedDir; while (true) { const gitDir = path.join(currentDir, '.git'); // Check if .git exists (either as directory or file for worktrees) if (fs.existsSync(gitDir)) { + // Cache the result for this directory and all parent directories + gitRepoCache.set(cacheKey, true); return true; } @@ -28,6 +43,8 @@ export function isGitRepository(directory: string): boolean { // If we've reached the root directory, stop searching if (parentDir === currentDir) { + // Cache the result for this directory + gitRepoCache.set(cacheKey, false); break; } @@ -37,6 +54,8 @@ export function isGitRepository(directory: string): boolean { return false; } catch (_error) { // If any filesystem error occurs, assume not a git repo + // Cache this result as well + gitRepoCache.set(cacheKey, false); return false; } } @@ -47,27 +66,52 @@ export function isGitRepository(directory: string): boolean { * @returns The git repository root path, or null if not in a git repository */ export function findGitRoot(directory: string): string | null { + const resolvedDir = path.resolve(directory); + const cacheKey = `gitRoot:${resolvedDir}`; + + // Check if result is already cached + const cached = gitRepoCache.get(cacheKey); + if (cached !== undefined) { + return cached as string | null; + } + try { - let currentDir = path.resolve(directory); + let currentDir = resolvedDir; while (true) { const gitDir = path.join(currentDir, '.git'); if (fs.existsSync(gitDir)) { + // Cache the result for this directory + gitRepoCache.set(cacheKey, currentDir); return currentDir; } const parentDir = path.dirname(currentDir); if (parentDir === currentDir) { - break; + // Cache the result as null since no git root was found + gitRepoCache.set(cacheKey, null); + return null; } currentDir = parentDir; } - - return null; } catch (_error) { + // Cache the error result as null + gitRepoCache.set(cacheKey, null); return null; } } + +/** + * Clear the git repository cache, useful for when directory structures change + */ +export function clearGitCache(): void { + // Only clear git-related cache entries + for (const key of gitRepoCache['cache'].keys()) { + if (key.startsWith('isGitRepo:') || key.startsWith('gitRoot:')) { + gitRepoCache['cache'].delete(key); + } + } +} diff --git a/packages/core/src/utils/request-deduplicator.ts b/packages/core/src/utils/request-deduplicator.ts new file mode 100644 index 000000000..e20958275 --- /dev/null +++ b/packages/core/src/utils/request-deduplicator.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Request deduplicator to prevent multiple concurrent requests to the same endpoint + * This is useful for reducing redundant API calls when multiple parts of the application + * request the same data simultaneously + */ +export class RequestDeduplicator { + private readonly pendingRequests = new Map>(); + + /** + * Execute a request function, but deduplicate if an identical request is already in progress + * @param key A unique key identifying the request (e.g. URL + parameters) + * @param requestFn The function to execute if no duplicate is in progress + * @returns The result of the request + */ + async execute(key: string, requestFn: () => Promise): Promise { + // Check if there's already a pending request with this key + const existingRequest = this.pendingRequests.get(key); + if (existingRequest) { + // If there's already a pending request, wait for it instead of making a new one + try { + return (await existingRequest) as Promise; + } finally { + // Clean up the map after the request completes (regardless of success or failure) + this.pendingRequests.delete(key); + } + } + + // If no pending request exists, create a new one + const requestPromise = (async () => { + try { + return await requestFn(); + } finally { + // Clean up the map after the request completes + this.pendingRequests.delete(key); + } + })(); + + // Store the pending request + this.pendingRequests.set(key, requestPromise as Promise); + + try { + return await requestPromise; + } finally { + // Ensure cleanup in case the promise rejects immediately + this.pendingRequests.delete(key); + } + } + + /** + * Get the number of currently pending deduplicated requests + */ + getPendingRequestCount(): number { + return this.pendingRequests.size; + } + + /** + * Clear all pending requests (useful for testing or when context changes) + */ + clear(): void { + this.pendingRequests.clear(); + } +} + +// Global instance for application-wide deduplication +export const requestDeduplicator = new RequestDeduplicator(); diff --git a/packages/core/src/utils/request-tokenizer/textTokenizer.ts b/packages/core/src/utils/request-tokenizer/textTokenizer.ts index 86c71d4c5..317a09dfb 100644 --- a/packages/core/src/utils/request-tokenizer/textTokenizer.ts +++ b/packages/core/src/utils/request-tokenizer/textTokenizer.ts @@ -7,11 +7,13 @@ import type { TiktokenEncoding, Tiktoken } from 'tiktoken'; import { get_encoding } from 'tiktoken'; +// Cache encodings globally to reuse across instances +const encodingCache = new Map(); + /** * Text tokenizer for calculating text tokens using tiktoken */ export class TextTokenizer { - private encoding: Tiktoken | null = null; private encodingName: string; constructor(encodingName: string = 'cl100k_base') { @@ -21,18 +23,23 @@ export class TextTokenizer { /** * Initialize the tokenizer (lazy loading) */ - private async ensureEncoding(): Promise { - if (this.encoding) return; + private async ensureEncoding(): Promise { + // Check if we already have this encoding cached + if (encodingCache.has(this.encodingName)) { + return encodingCache.get(this.encodingName) || null; + } try { // Use type assertion since we know the encoding name is valid - this.encoding = get_encoding(this.encodingName as TiktokenEncoding); + const encoding = get_encoding(this.encodingName as TiktokenEncoding); + encodingCache.set(this.encodingName, encoding); + return encoding; } catch (error) { console.warn( `Failed to load tiktoken with encoding ${this.encodingName}:`, error, ); - this.encoding = null; + return null; } } @@ -42,11 +49,11 @@ export class TextTokenizer { async calculateTokens(text: string): Promise { if (!text) return 0; - await this.ensureEncoding(); + const encoding = await this.ensureEncoding(); - if (this.encoding) { + if (encoding) { try { - return this.encoding.encode(text).length; + return encoding.encode(text).length; } catch (error) { console.warn('Error encoding text with tiktoken:', error); } @@ -61,14 +68,13 @@ export class TextTokenizer { * Calculate tokens for multiple text strings in parallel */ async calculateTokensBatch(texts: string[]): Promise { - await this.ensureEncoding(); + const encoding = await this.ensureEncoding(); - if (this.encoding) { + if (encoding) { try { return texts.map((text) => { if (!text) return 0; - // this.encoding may be null, add a null check to satisfy lint - return this.encoding ? this.encoding.encode(text).length : 0; + return encoding.encode(text).length; }); } catch (error) { console.warn('Error encoding texts with tiktoken:', error); @@ -85,13 +91,21 @@ export class TextTokenizer { * Dispose of resources */ dispose(): void { - if (this.encoding) { + if (encodingCache.has(this.encodingName)) { try { - this.encoding.free(); + const encoding = encodingCache.get(this.encodingName)!; + encoding.free(); + encodingCache.delete(this.encodingName); } catch (error) { console.warn('Error freeing tiktoken encoding:', error); } - this.encoding = null; } } + + /** + * Get the encoding instance, useful for external consumers who want to reuse it + */ + async getEncoding(): Promise { + return await this.ensureEncoding(); + } }