diff --git a/.changeset/clever-moments-melt.md b/.changeset/clever-moments-melt.md new file mode 100644 index 00000000000..d648d7d62d6 --- /dev/null +++ b/.changeset/clever-moments-melt.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fixed that some tasks in task history were red diff --git a/.changeset/ten-ravens-lose.md b/.changeset/ten-ravens-lose.md new file mode 100644 index 00000000000..02ceaa065bb --- /dev/null +++ b/.changeset/ten-ravens-lose.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Improve support for VSCode's HTTP proxy settings diff --git a/apps/storybook/.storybook/preview.ts b/apps/storybook/.storybook/preview.ts index c9dad7af64a..1b744f7748a 100644 --- a/apps/storybook/.storybook/preview.ts +++ b/apps/storybook/.storybook/preview.ts @@ -1,6 +1,7 @@ import type { Preview } from "@storybook/react-vite" import { withExtensionState } from "../src/decorators/withExtensionState" +import { withPostMessageMock } from "../src/decorators/withPostMessageMock" import { withQueryClient } from "../src/decorators/withQueryClient" import { withTheme } from "../src/decorators/withTheme" import { withI18n } from "../src/decorators/withI18n" @@ -49,6 +50,7 @@ const preview: Preview = { withI18n, withQueryClient, withExtensionState, + withPostMessageMock, withTheme, withTooltipProvider, withFixedContainment, diff --git a/apps/storybook/src/decorators/withPostMessageMock.tsx b/apps/storybook/src/decorators/withPostMessageMock.tsx new file mode 100644 index 00000000000..54eae281fde --- /dev/null +++ b/apps/storybook/src/decorators/withPostMessageMock.tsx @@ -0,0 +1,43 @@ +import type { Decorator } from "@storybook/react-vite" +import React from "react" + +type PostMessage = Record + +/** + * Decorator to mock VSCode postMessage for components that listen to messages. + * + * To override in a story, use parameters.postMessages: + * ```tsx + * export const MyStory: Story = { + * parameters: { + * postMessages: [ + * { type: "kilocodeNotificationsResponse", notifications: [...] }, + * ], + * }, + * } + * ``` + * + * Multiple messages are sent sequentially + */ +export const withPostMessageMock: Decorator = (Story, context) => { + const messages = context.parameters?.postMessages as PostMessage[] | undefined + + React.useEffect(() => { + if (!messages || messages.length === 0) { + return + } + + const timers: NodeJS.Timeout[] = [] + messages.forEach((message, index) => { + const event = new MessageEvent("message", { data: message }) + const timer = setTimeout(() => { + window.dispatchEvent(event) + }) + timers.push(timer) + }) + + return () => timers.forEach(clearTimeout) + }, [messages]) + + return +} diff --git a/apps/storybook/src/utils/createExtensionStateMock.ts b/apps/storybook/src/utils/createExtensionStateMock.ts index aa3821ff091..1bca08eaacf 100644 --- a/apps/storybook/src/utils/createExtensionStateMock.ts +++ b/apps/storybook/src/utils/createExtensionStateMock.ts @@ -17,10 +17,7 @@ export const createExtensionStateMock = ( ): ExtensionStateContextType => { // Only define properties that Storybook stories actually use const knownProperties: Partial = { - // Add properties here as they're needed in stories - // For example: - // theme: {}, - // apiConfiguration: null, + kilocodeDefaultModel: "claude-sonnet-4", } // Merge with overrides diff --git a/apps/storybook/stories/KilocodeNotifications.stories.tsx b/apps/storybook/stories/KilocodeNotifications.stories.tsx new file mode 100644 index 00000000000..7cd3638a4a0 --- /dev/null +++ b/apps/storybook/stories/KilocodeNotifications.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" +import { fn } from "storybook/test" +import { KilocodeNotifications } from "@/components/kilocode/KilocodeNotifications" + +const meta = { + title: "Components/KilocodeNotifications", + component: KilocodeNotifications, + parameters: { + extensionState: { + dismissedNotificationIds: [], + }, + }, + args: { + onDismiss: fn(), + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +const defaultNotification = { + id: "1", + title: "Welcome to Kilo Code!", + message: "Get started by setting up your API configuration in the settings.", + action: { + actionText: "Open Settings", + actionURL: "https://example.com/settings", + }, +} + +export const Default: Story = { + parameters: { + postMessages: [ + { + type: "kilocodeNotificationsResponse", + notifications: [defaultNotification], + }, + ], + }, +} + +export const MultipleNotifications: Story = { + parameters: { + postMessages: [ + { + type: "kilocodeNotificationsResponse", + notifications: [ + { + id: "1", + title: "First Notification", + message: "This is the first notification in a series.", + }, + { + id: "2", + title: "Second Notification", + message: "You can navigate between notifications using the arrows.", + }, + { + id: "3", + title: "Third Notification", + message: "This is the last notification in this set.", + action: { + actionText: "Visit Website", + actionURL: "https://example.com", + }, + }, + ], + }, + ], + }, +} diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index 14d34491506..a856613f0b5 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -19,7 +19,6 @@ export const historyItemSchema = z.object({ size: z.number().optional(), workspace: z.string().optional(), isFavorited: z.boolean().optional(), // kilocode_change - fileNotfound: z.boolean().optional(), // kilocode_change mode: z.string().optional(), status: z.enum(["active", "completed", "delegated"]).optional(), delegatedToId: z.string().optional(), // Last child this parent delegated to diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efeeb3fc5dc..36c16a5f10d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1934,9 +1934,6 @@ importers: turndown: specifier: ^7.2.0 version: 7.2.0 - undici: - specifier: '>=5.29.0' - version: 7.16.0 uri-js: specifier: ^4.4.1 version: 4.4.1 diff --git a/src/api/providers/__tests__/fetch-with-timeout.spec.ts b/src/api/providers/__tests__/fetch-with-timeout.spec.ts deleted file mode 100644 index 62cca5f38eb..00000000000 --- a/src/api/providers/__tests__/fetch-with-timeout.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -// kilocode_change - file added -// npx vitest run api/providers/__tests__/fetch-with-timeout.spec.ts - -import { vi, describe, it, expect, beforeEach } from "vitest" - -// Declare hoisted mocks to be safely referenced inside vi.mock factory -const hoisted = vi.hoisted(() => { - return { - mockFetch: vi.fn(), - mockAgentConstructor: vi.fn(), - agentInstances: [] as any[], - } -}) - -// Mock the undici module used by the implementation -vi.mock("undici", () => { - // Create a mock HeadersTimeoutError class - class HeadersTimeoutError extends Error { - constructor(message?: string) { - super(message) - this.name = "HeadersTimeoutError" - } - } - - return { - EnvHttpProxyAgent: vi.fn().mockImplementation((opts: any) => { - hoisted.mockAgentConstructor(opts) - const instance = { __mock: "EnvHttpProxyAgent" } - hoisted.agentInstances.push(instance) - return instance - }), - fetch: hoisted.mockFetch, - errors: { - HeadersTimeoutError, - }, - } -}) - -// Import after mocking so the implementation picks up our mocks -import { fetchWithTimeout } from "../kilocode/fetchWithTimeout" - -describe("fetchWithTimeout - header precedence and timeout wiring", () => { - beforeEach(() => { - vi.clearAllMocks() - hoisted.agentInstances.length = 0 - }) - - it("should prefer persistent headers over request-specific headers (application/json overrides text/plain)", async () => { - hoisted.mockFetch.mockResolvedValueOnce({ ok: true } as any) - - const timeoutMs = 5_000 - const f = fetchWithTimeout(timeoutMs, { - "Content-Type": "application/json", - "X-Test": "A", - }) - - await f("http://example.com", { - method: "POST", - headers: { - "Content-Type": "text/plain", - "X-Test": "B", - }, - body: '{"x":1}', - }) - - // Agent constructed with correct timeouts - expect(hoisted.mockAgentConstructor).toHaveBeenCalledWith({ - headersTimeout: timeoutMs, - bodyTimeout: timeoutMs, - }) - - // Fetch called with merged headers where persistent wins - expect(hoisted.mockFetch).toHaveBeenCalledTimes(1) - const [url, init] = hoisted.mockFetch.mock.calls[0] - expect(url).toBe("http://example.com") - - // Dispatcher is the agent instance we created - expect(init.dispatcher).toBe(hoisted.agentInstances[0]) - - // Persistent headers must override request-specific ones - expect(init.headers).toEqual( - expect.objectContaining({ - "Content-Type": "application/json", - "X-Test": "A", - }), - ) - }) - - it("should apply persistent application/json when request-specific Content-Type is omitted (prevents defaulting to text/plain)", async () => { - hoisted.mockFetch.mockResolvedValueOnce({ ok: true } as any) - - const f = fetchWithTimeout(10_000, { - "Content-Type": "application/json", - }) - - await f("http://example.com", { - method: "POST", - body: '{"x":1}', - }) - - expect(hoisted.mockFetch).toHaveBeenCalledTimes(1) - const [, init] = hoisted.mockFetch.mock.calls[0] - - // Ensure Content-Type remains application/json - expect(init.headers).toEqual( - expect.objectContaining({ - "Content-Type": "application/json", - }), - ) - }) -}) diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index d31843f37b1..e8cced21420 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -13,7 +13,6 @@ import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import { verifyFinishReason } from "./kilocode/verifyFinishReason" import { handleOpenAIError } from "./utils/openai-error-handler" -import { fetchWithTimeout } from "./kilocode/fetchWithTimeout" // kilocode_change import { calculateApiCostOpenAI } from "../../shared/cost" import { getApiRequestTimeout } from "./utils/timeout-config" @@ -61,15 +60,11 @@ export abstract class BaseOpenAiCompatibleProvider throw new Error("API key is required") } - const timeout = getApiRequestTimeout() // kilocode_change this.client = new OpenAI({ baseURL, apiKey: this.options.apiKey, defaultHeaders: DEFAULT_HEADERS, - // kilocode_change start - timeout: timeout, - fetch: timeout ? fetchWithTimeout(timeout) : undefined, - // kilocode_change end + timeout: getApiRequestTimeout(), }) } diff --git a/src/api/providers/kilocode/fetchWithTimeout.ts b/src/api/providers/kilocode/fetchWithTimeout.ts deleted file mode 100644 index 1ed43f4116f..00000000000 --- a/src/api/providers/kilocode/fetchWithTimeout.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as undici from "undici" - -export const HeadersTimeoutError = undici.errors.HeadersTimeoutError - -export function fetchWithTimeout(timeoutMs: number, headers?: Record): typeof fetch { - const agent = new undici.EnvHttpProxyAgent({ - headersTimeout: timeoutMs, - bodyTimeout: timeoutMs, - }) - return (input, init) => { - const requestInit: undici.RequestInit = { - ...(init as undici.RequestInit), - dispatcher: agent, - } - - if (headers) { - requestInit.headers = { - ...(init?.headers || {}), - ...headers, - } - } - - return undici.fetch(input as undici.RequestInfo, requestInit) as unknown as Promise - } -} diff --git a/src/api/providers/kilocode/getKilocodeDefaultModel.ts b/src/api/providers/kilocode/getKilocodeDefaultModel.ts index b3cdcaa3b6b..7e71cbc0e45 100644 --- a/src/api/providers/kilocode/getKilocodeDefaultModel.ts +++ b/src/api/providers/kilocode/getKilocodeDefaultModel.ts @@ -2,7 +2,6 @@ import { openRouterDefaultModelId, type ProviderSettings } from "@roo-code/types import { getKiloUrlFromToken } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { z } from "zod" -import { fetchWithTimeout } from "./fetchWithTimeout" import { DEFAULT_HEADERS } from "../constants" type KilocodeToken = string @@ -15,8 +14,6 @@ const defaultsSchema = z.object({ defaultModel: z.string().nullish(), }) -const fetcher = fetchWithTimeout(5000) - async function fetchKilocodeDefaultModel( kilocodeToken: KilocodeToken, organizationId?: OrganizationId, @@ -39,7 +36,10 @@ async function fetchKilocodeDefaultModel( headers["X-KILOCODE-TESTER"] = "SUPPRESS" } - const response = await fetcher(url, { headers }) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5000) + const response = await fetch(url, { headers, signal: controller.signal }) + clearTimeout(timeout) if (!response.ok) { throw new Error(`Fetching default model from ${url} failed: ${response.status}`) } diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts index bbf545ac161..f66f842d7ef 100644 --- a/src/api/providers/lm-studio.ts +++ b/src/api/providers/lm-studio.ts @@ -13,11 +13,10 @@ import { ApiStream } from "../transform/stream" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import { fetchWithTimeout, HeadersTimeoutError } from "./kilocode/fetchWithTimeout" import { addNativeToolCallsToParams, ToolCallAccumulator } from "./kilocode/nativeToolCallHelpers" -import { getModels, getModelsFromCache } from "./fetchers/modelCache" +import { getModelsFromCache } from "./fetchers/modelCache" +import { getApiRequestTimeout } from "./utils/timeout-config" import { handleOpenAIError } from "./utils/openai-error-handler" -import { getApiRequestTimeout } from "./utils/timeout-config" // kilocode_change export class LmStudioHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions @@ -27,14 +26,14 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan constructor(options: ApiHandlerOptions) { super() this.options = options - const timeout = getApiRequestTimeout() // kilocode_change + + // LM Studio uses "noop" as a placeholder API key + const apiKey = "noop" + this.client = new OpenAI({ baseURL: (this.options.lmStudioBaseUrl || "http://localhost:1234") + "/v1", - apiKey: "noop", - // kilocode_change start - timeout: timeout, - fetch: timeout ? fetchWithTimeout(timeout) : undefined, - // kilocode_change end + apiKey: apiKey, + timeout: getApiRequestTimeout(), }) } @@ -146,11 +145,6 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan outputTokens, } as const } catch (error) { - // kilocode_change start - if (error.cause instanceof HeadersTimeoutError) { - throw new Error("Headers timeout", { cause: error }) - } - // kilocode_change end throw new Error( "Please check the LM Studio developer logs to debug what went wrong. You may need to load the model with a larger context length to work with Kilo Code's prompts.", ) diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index 4f4d3ef0620..43ccd06512c 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -15,9 +15,6 @@ import { XmlMatcher } from "../../utils/xml-matcher" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" // kilocode_change start -import { fetchWithTimeout, HeadersTimeoutError } from "./kilocode/fetchWithTimeout" -import { getApiRequestTimeout } from "./utils/timeout-config" - const TOKEN_ESTIMATION_FACTOR = 4 //Industry standard technique for estimating token counts without actually implementing a parser/tokenizer function estimateOllamaTokenCount(messages: Message[]): number { @@ -195,20 +192,19 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio private ensureClient(): Ollama { if (!this.client) { try { - // kilocode_change start - const headers = this.options.ollamaApiKey - ? { Authorization: `Bearer ${this.options.ollamaApiKey}` } - : undefined - // kilocode_change end - - const timeout = getApiRequestTimeout() // kilocode_change - this.client = new Ollama({ + const clientOptions: OllamaOptions = { host: this.options.ollamaBaseUrl || "http://localhost:11434", - // kilocode_change start - fetch: timeout ? fetchWithTimeout(timeout, headers) : undefined, - headers: headers, - // kilocode_change end - }) + // Note: The ollama npm package handles timeouts internally + } + + // Add API key if provided (for Ollama cloud or authenticated instances) + if (this.options.ollamaApiKey) { + clientOptions.headers = { + Authorization: `Bearer ${this.options.ollamaApiKey}`, + } + } + + this.client = new Ollama(clientOptions) } catch (error: any) { throw new Error(`Error creating Ollama client: ${error.message}`) } @@ -365,12 +361,6 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio const statusCode = error.status || error.statusCode const errorMessage = error.message || "Unknown error" - // kilocode_change start - if (error.cause instanceof HeadersTimeoutError) { - throw new Error("Headers timeout", { cause: error }) - } - // kilocode_change end - if (error.code === "ECONNREFUSED") { throw new Error( `Ollama service is not running at ${this.options.ollamaBaseUrl || "http://localhost:11434"}. Please start Ollama first.`, diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 5fb60bde6df..7e5b87c8916 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -44,6 +44,7 @@ type OpenRouterProviderParams = { import { safeJsonParse } from "../../shared/safeJsonParse" import { isAnyRecognizedKiloCodeError } from "../../shared/kilocode/errorUtils" +import { OpenAIError } from "openai" // kilocode_change end import type { ApiHandlerCreateMessageMetadata, SingleCompletionHandler } from "../index" @@ -206,10 +207,31 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH throw new Error(`OpenRouter API Error ${error?.code}: ${rawErrorMessage}`) } + // kilocode_change start + // the comment below seems incorrect, errors in the stream are still thrown as exceptions override async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, + ): AsyncGenerator { + try { + yield* this.createMessage_implementationRenamedForKilocode(systemPrompt, messages, metadata) + } catch (error) { + if ( + error instanceof OpenAIError && + (this.providerName !== "KiloCode" || !isAnyRecognizedKiloCodeError(error)) + ) { + throw new Error(makeOpenRouterErrorReadable(error)) + } + throw error + } + } + // kilocode_change end + + private async *createMessage_implementationRenamedForKilocode( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, ): AsyncGenerator { const model = await this.fetchModel() @@ -641,7 +663,7 @@ function makeOpenRouterErrorReadable(error: any) { rawError?.message ?? metadata?.raw ?? error?.message - throw new Error(`${metadata?.provider_name ?? "Provider"} error: ${errorMessage ?? "unknown error"}`) + return `${metadata?.provider_name ?? "Provider"} error: ${errorMessage ?? "unknown error"}` } try { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 68792066db1..9eebcff0be7 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1816,8 +1816,13 @@ export class ClineProvider // if we tried to get a task that doesn't exist, remove it from state // FIXME: this seems to happen sometimes when the json file doesnt save to disk for some reason - // await this.deleteTaskFromState(id) // kilocode_change disable confusing behaviour - await this.setTaskFileNotFound(id) // kilocode_change + // kilocode_change start + // commented out deleting the task, because in the previous version we made this task red + // instead of deleting, and people were confused because the task was actually working fine + // which leads us to believe that this is triggered to often somehow, or that the task will turn up later + // via some sync ( context https://github.com/Kilo-Org/kilocode/pull/4880 ) + // await this.deleteTaskFromState(id) + // kilocode_change end throw new Error("Task not found") } @@ -3667,19 +3672,6 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont } } - async setTaskFileNotFound(id: string) { - const history = this.getGlobalState("taskHistory") ?? [] - const updatedHistory = history.map((item) => { - if (item.id === id) { - return { ...item, fileNotfound: true } - } - return item - }) - await this.updateGlobalState("taskHistory", updatedHistory) - this.kiloCodeTaskHistoryVersion++ - await this.postStateToWebview() - } - private kiloCodeTaskHistoryVersion = 0 private kiloCodeTaskHistorySizeForTelemetryOnly = 0 diff --git a/src/package.json b/src/package.json index 2ecf0703507..be31d7590f7 100644 --- a/src/package.json +++ b/src/package.json @@ -749,7 +749,6 @@ "tmp": "^0.2.3", "tree-sitter-wasms": "^0.1.12", "turndown": "^7.2.0", - "undici": "^7.13.0", "uri-js": "^4.4.1", "uuid": "^11.1.0", "vscode-material-icons": "^0.1.1", diff --git a/src/services/ghost/__tests__/GhostModel.spec.ts b/src/services/ghost/__tests__/GhostModel.spec.ts index deb529fc6a6..07a782f44aa 100644 --- a/src/services/ghost/__tests__/GhostModel.spec.ts +++ b/src/services/ghost/__tests__/GhostModel.spec.ts @@ -119,6 +119,7 @@ describe("GhostModel", () => { // Mock TelemetryService const mockTelemetryService = { captureEvent: vi.fn(), + captureException: vi.fn(), isTelemetryEnabled: vi.fn().mockReturnValue(false), shutdown: vi.fn(), } diff --git a/webview-ui/src/components/history/TaskItem.tsx b/webview-ui/src/components/history/TaskItem.tsx index 69faf226874..3f19e64de87 100644 --- a/webview-ui/src/components/history/TaskItem.tsx +++ b/webview-ui/src/components/history/TaskItem.tsx @@ -48,10 +48,6 @@ const TaskItem = ({ data-testid={`task-item-${item.id}`} className={cn( "cursor-pointer group bg-vscode-editor-background rounded relative overflow-hidden border border-transparent hover:bg-vscode-list-hoverBackground transition-colors", // kilocode_change: no rounded borders - { - "bg-red-900 text-white": item.fileNotfound, // kilocode_change added this state instead of removing - "bg-vscode-editor-background": !item.fileNotfound, //kilocode_change this is the default normally in the regular classname list - }, className, )} onClick={handleClick}> diff --git a/webview-ui/src/components/kilocode/KilocodeNotifications.tsx b/webview-ui/src/components/kilocode/KilocodeNotifications.tsx index ce96fdcedd6..533f757146b 100644 --- a/webview-ui/src/components/kilocode/KilocodeNotifications.tsx +++ b/webview-ui/src/components/kilocode/KilocodeNotifications.tsx @@ -119,7 +119,7 @@ export const KilocodeNotifications: React.FC = () => {

{currentNotification.title}

@@ -146,7 +146,7 @@ export const KilocodeNotifications: React.FC = () => {
@@ -155,7 +155,7 @@ export const KilocodeNotifications: React.FC = () => { diff --git a/webview-ui/src/utils/slash-commands.ts b/webview-ui/src/utils/slash-commands.ts index 598a35e55e1..a31816f3ce3 100644 --- a/webview-ui/src/utils/slash-commands.ts +++ b/webview-ui/src/utils/slash-commands.ts @@ -30,7 +30,7 @@ export function getSupportedSlashCommands( }, { name: "reportbug", description: "Create a KiloCode GitHub issue" }, { name: "smol", description: "Condenses your current context window" }, - { name: "session", description: "Session management " }, // kilocode_change + { name: "session", description: "Session management " }, // kilocode_change ] // Add mode-switching commands dynamically