diff --git a/src/core/config/loader.ts b/src/core/config/loader.ts index 5cd953b33..38efab53b 100644 --- a/src/core/config/loader.ts +++ b/src/core/config/loader.ts @@ -8,6 +8,7 @@ import { getCwd } from '@utils/state' import { safeParseJSON } from '@utils/text/json' import { ConfigParseError } from '@utils/text/errors' import { debug as debugLogger } from '@utils/log/debugLogger' +import { logStartupProfileDuration } from '@utils/config/startupProfile' import { DEFAULT_GLOBAL_CONFIG, DEFAULT_PROJECT_CONFIG, @@ -91,7 +92,7 @@ function findMatchingProjectKey( export function checkHasTrustDialogAccepted(): boolean { let currentPath = getCwd() - const config = getConfig(getGlobalConfigFilePath(), DEFAULT_GLOBAL_CONFIG) + const config = getLoadedGlobalConfig() while (true) { const projectKey = findMatchingProjectKey(config.projects, currentPath) @@ -117,6 +118,36 @@ const TEST_PROJECT_CONFIG_FOR_TESTING: ProjectConfig = { ...DEFAULT_PROJECT_CONFIG, } +let globalConfigCache: GlobalConfig | null = null +let globalConfigCachePath: string | null = null + +function cacheGlobalConfig(file: string, config: GlobalConfig): GlobalConfig { + const migrated = migrateModelProfilesRemoveId(config) + globalConfigCachePath = file + globalConfigCache = cloneDeep(migrated) + return cloneDeep(migrated) +} + +function getCachedGlobalConfig(file: string): GlobalConfig | null { + if (globalConfigCachePath !== file || !globalConfigCache) return null + return cloneDeep(globalConfigCache) +} + +function getLoadedGlobalConfig(throwOnInvalid?: boolean): GlobalConfig { + const file = getGlobalConfigFilePath() + const cached = getCachedGlobalConfig(file) + if (cached) return cached + return cacheGlobalConfig( + file, + getConfig(file, DEFAULT_GLOBAL_CONFIG, throwOnInvalid), + ) +} + +export function clearConfigCacheForTesting(): void { + globalConfigCache = null + globalConfigCachePath = null +} + export function saveGlobalConfig(config: GlobalConfig): void { if (process.env.NODE_ENV === 'test') { for (const key in config) { @@ -125,23 +156,24 @@ export function saveGlobalConfig(config: GlobalConfig): void { return } - saveConfig( - getGlobalConfigFilePath(), - { - ...config, - projects: getConfig(getGlobalConfigFilePath(), DEFAULT_GLOBAL_CONFIG) - .projects, - }, - DEFAULT_GLOBAL_CONFIG, - ) + const file = getGlobalConfigFilePath() + const existingConfig = + getCachedGlobalConfig(file) ?? getConfig(file, DEFAULT_GLOBAL_CONFIG) + const nextConfig = { + ...config, + projects: existingConfig.projects, + } + + if (saveConfig(file, nextConfig, DEFAULT_GLOBAL_CONFIG)) { + cacheGlobalConfig(file, nextConfig) + } } export function getGlobalConfig(): GlobalConfig { if (process.env.NODE_ENV === 'test') { return TEST_GLOBAL_CONFIG_FOR_TESTING } - const config = getConfig(getGlobalConfigFilePath(), DEFAULT_GLOBAL_CONFIG) - return migrateModelProfilesRemoveId(config) + return getLoadedGlobalConfig() } export function normalizeApiKeyForConfig(apiKey: string): string { @@ -165,7 +197,8 @@ function saveConfig( file: string, config: A, defaultConfig: A, -): void { +): boolean { + const startedAt = Date.now() const filteredConfig = Object.fromEntries( Object.entries(config).filter( ([key, value]) => @@ -174,6 +207,8 @@ function saveConfig( ) try { writeFileSync(file, JSON.stringify(filteredConfig, null, 2), 'utf-8') + logStartupProfileDuration('config_write', Date.now() - startedAt, { file }) + return true } catch (error) { const err = error as NodeJS.ErrnoException if ( @@ -185,7 +220,7 @@ function saveConfig( file, reason: String(err.code), }) - return + return false } throw error } @@ -195,7 +230,7 @@ let configReadingAllowed = false export function enableConfigs(): void { configReadingAllowed = true - getConfig(getGlobalConfigFilePath(), DEFAULT_GLOBAL_CONFIG, true) + getLoadedGlobalConfig(true) } function getConfig( @@ -203,6 +238,7 @@ function getConfig( defaultConfig: A, throwOnInvalid?: boolean, ): A { + const startedAt = Date.now() void configReadingAllowed debugLogger.state('CONFIG_LOAD_START', { @@ -217,7 +253,12 @@ function getConfig( reason: 'file_not_exists', defaultConfigKeys: Object.keys(defaultConfig as object).join(', '), }) - return cloneDeep(defaultConfig) + const config = cloneDeep(defaultConfig) + logStartupProfileDuration('config_read', Date.now() - startedAt, { + file, + source: 'default', + }) + return config } try { @@ -246,6 +287,10 @@ function getConfig( finalConfigKeys: Object.keys(finalConfig as object).join(', '), }) + logStartupProfileDuration('config_read', Date.now() - startedAt, { + file, + source: 'file', + }) return finalConfig } catch (error) { const errorMessage = @@ -278,7 +323,12 @@ function getConfig( action: 'using_default_config', }) - return cloneDeep(defaultConfig) + const config = cloneDeep(defaultConfig) + logStartupProfileDuration('config_read', Date.now() - startedAt, { + file, + source: 'fallback', + }) + return config } } @@ -288,7 +338,7 @@ export function getCurrentProjectConfig(): ProjectConfig { } const absolutePath = resolve(getCwd()) - const config = getConfig(getGlobalConfigFilePath(), DEFAULT_GLOBAL_CONFIG) + const config = getLoadedGlobalConfig() if (!config.projects) { return defaultConfigForProject(absolutePath) @@ -321,22 +371,22 @@ export function saveCurrentProjectConfig(projectConfig: ProjectConfig): void { } return } - const config = getConfig(getGlobalConfigFilePath(), DEFAULT_GLOBAL_CONFIG) + const config = getLoadedGlobalConfig() const resolvedCwd = resolve(getCwd()) const existingKey = findMatchingProjectKey(config.projects, resolvedCwd) const storageKey = existingKey ?? resolvedCwd - - saveConfig( - getGlobalConfigFilePath(), - { - ...config, - projects: { - ...config.projects, - [storageKey]: projectConfig, - }, + const nextConfig = { + ...config, + projects: { + ...config.projects, + [storageKey]: projectConfig, }, - DEFAULT_GLOBAL_CONFIG, - ) + } + + const file = getGlobalConfigFilePath() + if (saveConfig(file, nextConfig, DEFAULT_GLOBAL_CONFIG)) { + cacheGlobalConfig(file, nextConfig) + } } export async function isAutoUpdaterDisabled(): Promise { diff --git a/src/entrypoints/cli/runCli.tsx b/src/entrypoints/cli/runCli.tsx index c36d3ccd0..6757439d9 100644 --- a/src/entrypoints/cli/runCli.tsx +++ b/src/entrypoints/cli/runCli.tsx @@ -23,7 +23,6 @@ import { getConfigForCLI, listConfigForCLI, enableConfigs, - validateAndRepairAllGPT5Profiles, } from '@utils/config' import { cwd } from 'process' import { dateToFilename, logError, parseLogFilename } from '@utils/log' @@ -99,14 +98,6 @@ export async function runCli() { try { enableConfigs() - - queueMicrotask(() => { - try { - validateAndRepairAllGPT5Profiles() - } catch (repairError) { - logError(`GPT-5 configuration validation failed: ${repairError}`) - } - }) } catch (error: unknown) { if (error instanceof ConfigParseError) { await showInvalidConfigDialog({ error }) @@ -600,34 +591,15 @@ async function parseArgs( process.exit(1) } - const updateInfo = await (async () => { - try { - const [ - { getLatestVersion, getUpdateCommandSuggestions }, - semverMod, - ] = await Promise.all([ - import('@utils/session/autoUpdater'), - import('semver'), - ]) - const semver: any = (semverMod as any)?.default ?? semverMod - const gt = semver?.gt - if (typeof gt !== 'function') - return { - version: null as string | null, - commands: null as string[] | null, - } + // Start update check early, outside REPL render critical path + const updateCheckPromise = import('@utils/session/autoUpdater') + .then(({ getUpdateBannerInfo }) => getUpdateBannerInfo()) + .catch(() => ({ version: null, commands: null })) - const latest = await getLatestVersion() - if (latest && gt(latest, MACRO.VERSION)) { - const cmds = await getUpdateCommandSuggestions() - return { version: latest as string, commands: cmds as string[] } - } - } catch {} - return { - version: null as string | null, - commands: null as string[] | null, - } - })() + const updateInfo = { + version: null as string | null, + commands: null as string[] | null, + } if (needsResumeSelector) { const sessions = listKodeAgentSessions({ cwd }) @@ -656,6 +628,7 @@ async function parseArgs( forkSessionId={sessionId ? String(sessionId) : null} initialUpdateVersion={updateInfo.version} initialUpdateCommands={updateInfo.commands} + updateCheckPromise={updateCheckPromise} />, renderContextWithExitOnCtrlC, ) @@ -685,6 +658,7 @@ async function parseArgs( initialUpdateVersion={updateInfo.version} initialUpdateCommands={updateInfo.commands} initialMessages={initialMessages} + updateCheckPromise={updateCheckPromise} />, renderContext, ) diff --git a/src/tools/system/BashTool/BashTool.tsx b/src/tools/system/BashTool/BashTool.tsx index eed042470..ee07af5f3 100644 --- a/src/tools/system/BashTool/BashTool.tsx +++ b/src/tools/system/BashTool/BashTool.tsx @@ -9,6 +9,7 @@ import { Tool, ValidationResult, ToolUseContext } from '@tool' import { splitCommand } from '@utils/commands' import { isInDirectory } from '@utils/fs/file' import { logError } from '@utils/log' +import { logStartupProfileDuration } from '@utils/config/startupProfile' import { createAssistantMessage } from '@utils/messages' import { BunShell } from '@utils/bun/shell' import { getBunShellSandboxPlan } from '@utils/sandbox/bunShellSandboxPlan' @@ -700,6 +701,7 @@ export const BashTool = { } if (race.kind === 'done') { + const finalizeStartedAt = Date.now() const result = race.r stdout += (result.stdout || '').trim() + EOL @@ -743,6 +745,10 @@ export const BashTool = { interrupted: result.interrupted, } + logStartupProfileDuration( + 'bash_result_finalize', + Date.now() - finalizeStartedAt, + ) yield { type: 'result', resultForAssistant: this.renderResultForAssistant(data), diff --git a/src/ui/components/Logo.tsx b/src/ui/components/Logo.tsx index cb03bc42f..aa1d74500 100644 --- a/src/ui/components/Logo.tsx +++ b/src/ui/components/Logo.tsx @@ -16,6 +16,32 @@ const DEFAULT_UPDATE_COMMANDS = [ 'npm install -g @shareai-lab/kode@latest', ] as const +export function UpdateBanner({ + version, + commands, +}: { + version: string + commands?: string[] | null +}): React.ReactNode { + return ( + + + New version available: {version} (current: {MACRO.VERSION}) + + Run the following command to update: + + {' '} + {commands?.[1] ?? DEFAULT_UPDATE_COMMANDS[1]} + + {process.platform !== 'win32' && ( + + Note: you may need to prefix with "sudo" on macOS/Linux. + + )} + + ) +} + export function Logo({ mcpClients, isDefaultModel = false, @@ -52,22 +78,10 @@ export function Logo({ width={width} > {updateBannerVersion ? ( - - - New version available: {updateBannerVersion} (current:{' '} - {MACRO.VERSION}) - - Run the following command to update: - - {' '} - {updateBannerCommands?.[1] ?? DEFAULT_UPDATE_COMMANDS[1]} - - {process.platform !== 'win32' && ( - - Note: you may need to prefix with "sudo" on macOS/Linux. - - )} - + ) : null} Welcome to{' '} diff --git a/src/ui/components/PromptInput.tsx b/src/ui/components/PromptInput.tsx index e587881cf..506c7d581 100644 --- a/src/ui/components/PromptInput.tsx +++ b/src/ui/components/PromptInput.tsx @@ -16,8 +16,7 @@ import type { SetToolJSXFn, Tool } from '@tool' import { TokenWarning, WARNING_THRESHOLD } from './TokenWarning' import { useTerminalSize } from '@hooks/useTerminalSize' import { getTheme } from '@utils/theme' -import { getModelManager, reloadModelManager } from '@utils/model' -import { saveGlobalConfig } from '@utils/config' +import { getModelManager } from '@utils/model' import { setTerminalTitle } from '@utils/terminal' import { launchExternalEditor } from '@utils/system/externalEditor' import { @@ -67,6 +66,22 @@ async function interpretHashCommand(input: string): Promise { } } +let didScheduleGPT5ProfileRepair = false + +function scheduleDeferredGPT5ProfileRepair(): void { + if (didScheduleGPT5ProfileRepair) return + didScheduleGPT5ProfileRepair = true + setTimeout(() => { + void import('@utils/config') + .then(({ validateAndRepairAllGPT5Profiles }) => { + validateAndRepairAllGPT5Profiles() + }) + .catch(error => { + logError(`GPT-5 configuration validation failed: ${error}`) + }) + }, 0) +} + type Props = { commands: Command[] forkNumber: number @@ -137,6 +152,7 @@ function PromptInput({ useEffect(() => { if (!isDisabled && !isLoading) { logStartupProfile('prompt_ready') + scheduleDeferredGPT5ProfileRepair() } }, [isDisabled, isLoading]) diff --git a/src/ui/screens/REPL.tsx b/src/ui/screens/REPL.tsx index 6263b5434..b5cf981b1 100644 --- a/src/ui/screens/REPL.tsx +++ b/src/ui/screens/REPL.tsx @@ -7,7 +7,7 @@ import { CostThresholdDialog } from '@components/CostThresholdDialog' import * as React from 'react' import { useEffect, useMemo, useRef, useState, useCallback } from 'react' import { Command } from '@commands' -import { Logo } from '@components/Logo' +import { Logo, UpdateBanner } from '@components/Logo' import { Message } from '@components/Message' import { MessageResponse } from '@components/MessageResponse' import { MessageSelector } from '@components/MessageSelector' @@ -63,7 +63,7 @@ import { createAssistantMessage, } from '@utils/messages' import { getReplStaticPrefixLength } from '@utils/terminal/replStaticSplit' -import { getModelManager, ModelManager } from '@utils/model' +import { getModelManager } from '@utils/model' import { clearTerminal, updateTerminalTitle } from '@utils/terminal' import { BinaryFeedback } from '@components/binary-feedback/BinaryFeedback' import { getMaxThinkingTokens } from '@utils/model/thinking' @@ -71,6 +71,7 @@ import { getOriginalCwd } from '@utils/state' import { handleHashCommand } from '@utils/commands/hashCommand' import { debug as debugLogger } from '@utils/log/debugLogger' import { getToolPermissionContextForConversationKey } from '@utils/permissions/toolPermissionContextState' +import { logStartupProfileDuration } from '@utils/config/startupProfile' type Props = { commands: Command[] @@ -88,6 +89,10 @@ type Props = { isDefaultModel?: boolean initialUpdateVersion?: string | null initialUpdateCommands?: string[] | null + updateCheckPromise?: Promise<{ + version: string | null + commands: string[] | null + }> } export type BinaryFeedbackContext = { @@ -112,6 +117,7 @@ export function REPL({ isDefaultModel = true, initialUpdateVersion, initialUpdateCommands, + updateCheckPromise, }: Props): React.ReactNode { const [verboseConfig] = useState( () => verboseFromCLI ?? getGlobalConfig().verbose, @@ -153,8 +159,18 @@ export function REPL({ const [binaryFeedbackContext, setBinaryFeedbackContext] = useState(null) - const updateAvailableVersion = initialUpdateVersion ?? null - const updateCommands = initialUpdateCommands ?? null + const initialUpdateAvailableVersion = initialUpdateVersion ?? null + const initialUpdateCommandList = initialUpdateCommands ?? null + const [asyncUpdateInfo, setAsyncUpdateInfo] = useState<{ + version: string | null + commands: string[] | null + }>({ + version: null, + commands: null, + }) + const updateAvailableVersion = + initialUpdateAvailableVersion ?? asyncUpdateInfo.version + const updateCommands = initialUpdateCommandList ?? asyncUpdateInfo.commands const getBinaryFeedbackResponse = useCallback( ( @@ -228,7 +244,6 @@ export function REPL({ const newAbortController = new AbortController() setAbortController(newAbortController) - const model = new ModelManager(getGlobalConfig()).getModelName('main') const newMessages = await processUserInput( initialPrompt, 'prompt', @@ -270,13 +285,41 @@ export function REPL({ return } - const [systemPrompt, context, model, maxThinkingTokens] = - await Promise.all([ - getSystemPrompt({ disableSlashCommands }), - getContext(), - new ModelManager(getGlobalConfig()).getModelName('main'), - getMaxThinkingTokens([...messages, ...newMessages]), - ]) + const queryPrepStartedAt = Date.now() + const systemPromptPromise = (async () => { + const startedAt = Date.now() + try { + return await getSystemPrompt({ disableSlashCommands }) + } finally { + logStartupProfileDuration( + 'query_prep_system_prompt', + Date.now() - startedAt, + ) + } + })() + const contextPromise = (async () => { + const startedAt = Date.now() + try { + return await getContext() + } finally { + logStartupProfileDuration( + 'query_prep_context', + Date.now() - startedAt, + ) + } + })() + const modelStartedAt = Date.now() + void getModelManager().getModelName('main') + logStartupProfileDuration('query_prep_model', Date.now() - modelStartedAt) + const [systemPrompt, context, maxThinkingTokens] = await Promise.all([ + systemPromptPromise, + contextPromise, + getMaxThinkingTokens([...messages, ...newMessages]), + ]) + logStartupProfileDuration( + 'query_prep_total', + Date.now() - queryPrepStartedAt, + ) for await (const message of query( [...messages, ...newMessages], @@ -351,13 +394,37 @@ export function REPL({ return } - const [systemPrompt, context, model, maxThinkingTokens] = await Promise.all( - [ - getSystemPrompt({ disableSlashCommands }), - getContext(), - new ModelManager(getGlobalConfig()).getModelName('main'), - getMaxThinkingTokens([...messages, lastMessage]), - ], + const queryPrepStartedAt = Date.now() + const systemPromptPromise = (async () => { + const startedAt = Date.now() + try { + return await getSystemPrompt({ disableSlashCommands }) + } finally { + logStartupProfileDuration( + 'query_prep_system_prompt', + Date.now() - startedAt, + ) + } + })() + const contextPromise = (async () => { + const startedAt = Date.now() + try { + return await getContext() + } finally { + logStartupProfileDuration('query_prep_context', Date.now() - startedAt) + } + })() + const modelStartedAt = Date.now() + void getModelManager().getModelName('main') + logStartupProfileDuration('query_prep_model', Date.now() - modelStartedAt) + const [systemPrompt, context, maxThinkingTokens] = await Promise.all([ + systemPromptPromise, + contextPromise, + getMaxThinkingTokens([...messages, lastMessage]), + ]) + logStartupProfileDuration( + 'query_prep_total', + Date.now() - queryPrepStartedAt, ) let lastAssistantMessage: MessageType | null = null @@ -437,6 +504,22 @@ export function REPL({ }) }, []) + useEffect(() => { + if (initialUpdateAvailableVersion || !updateCheckPromise) return + let cancelled = false + updateCheckPromise + .then(updateInfo => { + if (cancelled || !updateInfo.version) return + setAsyncUpdateInfo(updateInfo) + }) + .catch(error => { + logError(`update-banner: ${error}`) + }) + return () => { + cancelled = true + } + }, [initialUpdateAvailableVersion, updateCheckPromise]) + useLogMessages(messages, messageLogName, forkNumber) useLogStartupTime() @@ -601,8 +684,8 @@ export function REPL({ @@ -616,8 +699,8 @@ export function REPL({ replStaticPrefixLength, mcpClients, isDefaultModel, - updateAvailableVersion, - updateCommands, + initialUpdateAvailableVersion, + initialUpdateCommandList, ], ) @@ -639,6 +722,12 @@ export function REPL({ item.jsx} /> + {!initialUpdateAvailableVersion && updateAvailableVersion ? ( + + ) : null} {transientItems.map(_ => _.jsx)} setUiRefreshCounter(prev => prev + 1)} setIsLoading={setIsLoading} setAbortController={setAbortController} uiRefreshCounter={uiRefreshCounter} diff --git a/src/ui/screens/ResumeConversation.tsx b/src/ui/screens/ResumeConversation.tsx index 7e8f2e428..e079fe893 100644 --- a/src/ui/screens/ResumeConversation.tsx +++ b/src/ui/screens/ResumeConversation.tsx @@ -29,6 +29,10 @@ type Props = { forkSessionId?: string | null initialUpdateVersion?: string | null initialUpdateCommands?: string[] | null + updateCheckPromise?: Promise<{ + version: string | null + commands: string[] | null + }> } export function ResumeConversation({ @@ -47,6 +51,7 @@ export function ResumeConversation({ forkSessionId, initialUpdateVersion, initialUpdateCommands, + updateCheckPromise, }: Props): React.ReactNode { async function onSelect(index: number) { try { @@ -82,6 +87,7 @@ export function ResumeConversation({ isDefaultModel={isDefaultModel} initialUpdateVersion={initialUpdateVersion} initialUpdateCommands={initialUpdateCommands} + updateCheckPromise={updateCheckPromise} />, { exitOnCtrlC: false, diff --git a/src/utils/bun/shell.ts b/src/utils/bun/shell.ts index f982640e9..e7ca83187 100644 --- a/src/utils/bun/shell.ts +++ b/src/utils/bun/shell.ts @@ -12,6 +12,14 @@ import { } from '@utils/log/taskOutputStore' type ShellChildProcess = ChildProcess & { exited: Promise } +type ShellStdio = ['ignore', 'pipe' | 'overlapped', 'pipe' | 'overlapped'] + +export function getShellStdioForPlatform( + platform: NodeJS.Platform = process.platform, +): ShellStdio { + const output = platform === 'win32' ? 'overlapped' : 'pipe' + return ['ignore', output, output] +} function whichSync(bin: string): string | null { try { @@ -33,7 +41,7 @@ function spawnWithExited(options: { const child = spawn(options.cmd[0], options.cmd.slice(1), { cwd: options.cwd, env: options.env ?? process.env, - stdio: ['inherit', 'pipe', 'pipe'], + stdio: getShellStdioForPlatform(), windowsHide: true, }) as ShellChildProcess diff --git a/src/utils/config/startupProfile.ts b/src/utils/config/startupProfile.ts index e4d31be46..106268804 100644 --- a/src/utils/config/startupProfile.ts +++ b/src/utils/config/startupProfile.ts @@ -19,3 +19,23 @@ export function logStartupProfile(event: StartupEvent): void { const ms = Math.round(process.uptime() * 1000) process.stderr.write(`[startup] ${event}=${ms}ms\n`) } + +export function logStartupProfileDuration( + event: string, + durationMs: number, + details?: Record, +): void { + if (!isEnabled()) return + + const suffix = details + ? Object.entries(details) + .filter((entry): entry is [string, string | number | boolean] => { + return entry[1] !== undefined + }) + .map(([key, value]) => `${key}=${String(value)}`) + .join(' ') + : '' + process.stderr.write( + `[startup] ${event}=${Math.round(durationMs)}ms${suffix ? ` ${suffix}` : ''}\n`, + ) +} diff --git a/src/utils/model/index.ts b/src/utils/model/index.ts index 0c9338de4..29dcbf8ef 100644 --- a/src/utils/model/index.ts +++ b/src/utils/model/index.ts @@ -2,6 +2,7 @@ import { memoize } from 'lodash-es' import { logError } from '@utils/log' import { debug as debugLogger } from '@utils/log/debugLogger' +import { logStartupProfileDuration } from '@utils/config/startupProfile' import { getGlobalConfig, ModelProfile, @@ -640,11 +641,13 @@ export class ModelManager { } private saveConfig(): void { + const startedAt = Date.now() const updatedConfig = { ...this.config, modelProfiles: this.modelProfiles, } saveGlobalConfig(updatedConfig) + logStartupProfileDuration('model_config_save', Date.now() - startedAt) } async getFallbackModel(): Promise { diff --git a/src/utils/session/autoUpdater.ts b/src/utils/session/autoUpdater.ts index 7875e4e15..1f58241d3 100644 --- a/src/utils/session/autoUpdater.ts +++ b/src/utils/session/autoUpdater.ts @@ -1,9 +1,15 @@ import { execFileNoThrow } from '@utils/system/execFileNoThrow' import { logError } from '@utils/log' +import { logStartupProfileDuration } from '@utils/config/startupProfile' import { MACRO } from '@constants/macros' import { PRODUCT_NAME } from '@constants/product' +export type UpdateBannerInfo = { + version: string | null + commands: string[] | null +} + async function getSemver() { const mod: any = await import('semver') return (mod?.default ?? mod) as { @@ -83,6 +89,26 @@ export async function getUpdateCommandSuggestions(): Promise { ] } +export async function getUpdateBannerInfo(): Promise { + const startedAt = Date.now() + try { + if (process.env.NODE_ENV === 'test') { + return { version: null, commands: null } + } + const semver = await getSemver() + const latest = await getLatestVersion() + if (latest && semver.gt(latest, MACRO.VERSION)) { + const commands = await getUpdateCommandSuggestions() + return { version: latest, commands } + } + } catch { + } finally { + logStartupProfileDuration('update_check', Date.now() - startedAt) + } + + return { version: null, commands: null } +} + export async function checkAndNotifyUpdate(): Promise { try { if (process.env.NODE_ENV === 'test') return diff --git a/tests/unit/config-loader-cache.test.ts b/tests/unit/config-loader-cache.test.ts new file mode 100644 index 000000000..26fe13d2b --- /dev/null +++ b/tests/unit/config-loader-cache.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { + clearConfigCacheForTesting, + enableConfigs, + getGlobalConfig, + saveGlobalConfig, +} from '../../src/core/config/loader' + +describe('config loader cache', () => { + const originalNodeEnv = process.env.NODE_ENV + const originalKodeConfigDir = process.env.KODE_CONFIG_DIR + const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR + let configDir = '' + let configFile = '' + + beforeEach(() => { + configDir = mkdtempSync(join(tmpdir(), 'kode-config-cache-')) + configFile = join(configDir, 'config.json') + process.env.NODE_ENV = 'development' + process.env.KODE_CONFIG_DIR = configDir + delete process.env.CLAUDE_CONFIG_DIR + clearConfigCacheForTesting() + }) + + afterEach(() => { + clearConfigCacheForTesting() + if (configDir && existsSync(configDir)) { + rmSync(configDir, { recursive: true, force: true }) + } + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = originalNodeEnv + } + if (originalKodeConfigDir === undefined) { + delete process.env.KODE_CONFIG_DIR + } else { + process.env.KODE_CONFIG_DIR = originalKodeConfigDir + } + if (originalClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir + } + }) + + test('enableConfigs caches subsequent global config reads', () => { + writeFileSync(configFile, JSON.stringify({ numStartups: 7 }), 'utf-8') + + enableConfigs() + writeFileSync(configFile, JSON.stringify({ numStartups: 99 }), 'utf-8') + + expect(getGlobalConfig().numStartups).toBe(7) + }) + + test('saveGlobalConfig preserves existing projects without using caller projects', () => { + const projectPath = join(configDir, 'project') + writeFileSync( + configFile, + JSON.stringify({ + numStartups: 1, + projects: { + [projectPath]: { + allowedTools: ['Bash'], + }, + }, + }), + 'utf-8', + ) + + enableConfigs() + saveGlobalConfig({ + ...(getGlobalConfig() as any), + numStartups: 2, + projects: { + shouldNotBeSaved: { + allowedTools: ['Edit'], + }, + }, + } as any) + + const saved = JSON.parse(readFileSync(configFile, 'utf-8')) + expect(saved.numStartups).toBe(2) + expect(saved.projects).toEqual({ + [projectPath]: { + allowedTools: ['Bash'], + }, + }) + }) + + test('saveGlobalConfig updates the in-memory cache after writing', () => { + writeFileSync(configFile, JSON.stringify({ numStartups: 3 }), 'utf-8') + + enableConfigs() + saveGlobalConfig({ ...(getGlobalConfig() as any), numStartups: 4 } as any) + writeFileSync(configFile, JSON.stringify({ numStartups: 99 }), 'utf-8') + + expect(getGlobalConfig().numStartups).toBe(4) + }) + + test('NODE_ENV=test continues to use the test config object', () => { + process.env.NODE_ENV = 'test' + clearConfigCacheForTesting() + const original = { ...(getGlobalConfig() as any) } + + try { + saveGlobalConfig({ + ...(getGlobalConfig() as any), + numStartups: 123, + } as any) + expect(getGlobalConfig().numStartups).toBe(123) + } finally { + saveGlobalConfig(original as any) + } + }) +}) diff --git a/tests/unit/file-permission-engine.test.ts b/tests/unit/file-permission-engine.test.ts index 17431e058..a8947c093 100644 --- a/tests/unit/file-permission-engine.test.ts +++ b/tests/unit/file-permission-engine.test.ts @@ -193,22 +193,26 @@ describe('Reference CLI parity: filesystem permission engine', () => { } }) - test('asks for UNC paths and does not provide suggestions', async () => { - const toolPermissionContext = createDefaultToolPermissionContext({ - isBypassPermissionsModeAvailable: true, - }) - const ctx = makeContext({ toolPermissionContext }) + test( + 'asks for UNC paths and does not provide suggestions', + async () => { + const toolPermissionContext = createDefaultToolPermissionContext({ + isBypassPermissionsModeAvailable: true, + }) + const ctx = makeContext({ toolPermissionContext }) - const result = await hasPermissionsToUseTool( - FileReadTool as any, - { file_path: '//server/share/file.txt' }, - ctx as any, - {} as any, - ) + const result = await hasPermissionsToUseTool( + FileReadTool as any, + { file_path: '//server/share/file.txt' }, + ctx as any, + {} as any, + ) - expect(result.result).toBe(false) - expect((result as any).suggestions).toBeUndefined() - }) + expect(result.result).toBe(false) + expect((result as any).suggestions).toBeUndefined() + }, + { timeout: 15_000 }, + ) test('asks for suspicious Windows path patterns and does not provide suggestions', async () => { const toolPermissionContext = createDefaultToolPermissionContext({ diff --git a/tests/unit/lsp-tool.test.ts b/tests/unit/lsp-tool.test.ts index 205006883..7a336b54d 100644 --- a/tests/unit/lsp-tool.test.ts +++ b/tests/unit/lsp-tool.test.ts @@ -94,25 +94,30 @@ describe('LSP tool (TypeScript backend)', () => { } }) - test('goToDefinition returns formatted location + counts', async () => { - const ctx = makeContext() - const input = { - operation: 'goToDefinition', - filePath, - line: 2, - character: 32, - } as const - - const events: any[] = [] - for await (const evt of (LspTool as any).call(input, ctx)) events.push(evt) - expect(events).toHaveLength(1) - - const out = events[0].data - expect(out.operation).toBe('goToDefinition') - expect(out.result).toContain('Defined in') - expect(out.resultCount).toBeGreaterThan(0) - expect(out.fileCount).toBeGreaterThan(0) - }) + test( + 'goToDefinition returns formatted location + counts', + async () => { + const ctx = makeContext() + const input = { + operation: 'goToDefinition', + filePath, + line: 2, + character: 32, + } as const + + const events: any[] = [] + for await (const evt of (LspTool as any).call(input, ctx)) + events.push(evt) + expect(events).toHaveLength(1) + + const out = events[0].data + expect(out.operation).toBe('goToDefinition') + expect(out.result).toContain('Defined in') + expect(out.resultCount).toBeGreaterThan(0) + expect(out.fileCount).toBeGreaterThan(0) + }, + { timeout: 15_000 }, + ) test('findReferences returns formatted grouped locations + counts', async () => { const ctx = makeContext() @@ -176,39 +181,45 @@ describe('LSP tool (TypeScript backend)', () => { expect(out.fileCount).toBe(1) }) - test('documentSymbol reflects on-disk file edits (mtime-based versions)', async () => { - const ctx = makeContext() - const input = { - operation: 'documentSymbol', - filePath, - line: 1, - character: 1, - } as const - - const events1: any[] = [] - for await (const evt of (LspTool as any).call(input, ctx)) events1.push(evt) - expect(events1).toHaveLength(1) - const out1 = events1[0].data - expect(out1.result).toContain('foo') - expect(out1.result).not.toContain('baz') - - const beforeMtime = statSync(filePath).mtimeMs - const updated = [ - 'export function foo() { return 1 }', - 'export function bar() { return foo() }', - 'export function baz() { return bar() }', - 'foo()', - '', - ].join('\n') - writeFileSync(filePath, updated, 'utf8') - utimesSync(filePath, new Date(), new Date(beforeMtime + 1000)) - - expect(statSync(filePath).mtimeMs).toBeGreaterThan(beforeMtime) - - const events2: any[] = [] - for await (const evt of (LspTool as any).call(input, ctx)) events2.push(evt) - expect(events2).toHaveLength(1) - const out2 = events2[0].data - expect(out2.result).toContain('baz') - }) + test( + 'documentSymbol reflects on-disk file edits (mtime-based versions)', + async () => { + const ctx = makeContext() + const input = { + operation: 'documentSymbol', + filePath, + line: 1, + character: 1, + } as const + + const events1: any[] = [] + for await (const evt of (LspTool as any).call(input, ctx)) + events1.push(evt) + expect(events1).toHaveLength(1) + const out1 = events1[0].data + expect(out1.result).toContain('foo') + expect(out1.result).not.toContain('baz') + + const beforeMtime = statSync(filePath).mtimeMs + const updated = [ + 'export function foo() { return 1 }', + 'export function bar() { return foo() }', + 'export function baz() { return bar() }', + 'foo()', + '', + ].join('\n') + writeFileSync(filePath, updated, 'utf8') + utimesSync(filePath, new Date(), new Date(beforeMtime + 1000)) + + expect(statSync(filePath).mtimeMs).toBeGreaterThan(beforeMtime) + + const events2: any[] = [] + for await (const evt of (LspTool as any).call(input, ctx)) + events2.push(evt) + expect(events2).toHaveLength(1) + const out2 = events2[0].data + expect(out2.result).toContain('baz') + }, + { timeout: 15_000 }, + ) }) diff --git a/tests/unit/shell-cmd-selection.test.ts b/tests/unit/shell-cmd-selection.test.ts index bbd46b56c..5565f16a8 100644 --- a/tests/unit/shell-cmd-selection.test.ts +++ b/tests/unit/shell-cmd-selection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { BunShell } from '@utils/bun/shell' +import { BunShell, getShellStdioForPlatform } from '@utils/bun/shell' describe('shell command selection', () => { test('win32 uses ComSpec when provided', () => { @@ -20,4 +20,20 @@ describe('shell command selection', () => { expect(cmd[1]).toBe('-c') expect(cmd[2]).toBe('echo hi') }) + + test('non-Windows shell stdio ignores stdin and pipes output', () => { + expect(getShellStdioForPlatform('linux')).toEqual([ + 'ignore', + 'pipe', + 'pipe', + ]) + }) + + test('Windows shell stdio ignores stdin and uses overlapped output pipes', () => { + expect(getShellStdioForPlatform('win32')).toEqual([ + 'ignore', + 'overlapped', + 'overlapped', + ]) + }) })