diff --git a/.changeset/fix-provider-auth-invalidation.md b/.changeset/fix-provider-auth-invalidation.md new file mode 100644 index 00000000000..179d57d8a54 --- /dev/null +++ b/.changeset/fix-provider-auth-invalidation.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Fixed default model falling back to the free model after login or org switch by invalidating cached provider state when auth changes. diff --git a/.changeset/gateway-models-feature-header.md b/.changeset/gateway-models-feature-header.md new file mode 100644 index 00000000000..4021e29ea48 --- /dev/null +++ b/.changeset/gateway-models-feature-header.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-gateway": patch +--- + +Include the feature header when requesting the models list from Kilo Gateway diff --git a/.changeset/git-stats-polling.md b/.changeset/git-stats-polling.md new file mode 100644 index 00000000000..a3e7e0d02dd --- /dev/null +++ b/.changeset/git-stats-polling.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Reduce git process load via visibility-aware polling and resolution caching in GitStatsPoller diff --git a/.changeset/terminal-context-mention.md b/.changeset/terminal-context-mention.md new file mode 100644 index 00000000000..3228399c01e --- /dev/null +++ b/.changeset/terminal-context-mention.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add @terminal context mention support to the chat input. Type @terminal to include your active VS Code terminal output as context, with output safety limits (500 lines / 50K chars) and truncation. Works in both the sidebar chat and Agent Manager. diff --git a/AGENTS.md b/AGENTS.md index dc8bc73271e..d71e9235a4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,6 +171,12 @@ Tests MUST test actual implementation, do not duplicate logic into a test. [Conventional Commits](https://www.conventionalcommits.org/) with scopes matching packages: `vscode`, `cli`, `agent-manager`, `sdk`, `ui`, `i18n`, `kilo-docs`, `gateway`, `telemetry`, `desktop`. Omit scope when spanning multiple packages. +## Changesets + +User-facing changes (features, fixes, breaking changes) require a changeset file for release notes. Run `bunx changeset add` or manually create `.changeset/.md`. Use `patch` for bug fixes, `minor` for new features, `major` for breaking changes. See `.changeset/README.md` for details. + +Changeset descriptions appear directly in release notes and are read by end users. Keep them concise and feature-oriented — describe **what changed from the user's perspective**, not implementation details. Write in imperative mood (e.g. "Support exporting conversations as markdown" not "Add a new export handler that serializes session messages to .md files"). + ## Pull Requests PR descriptions should be 2-3 lines covering **what** changed and **why**. Focus on intent and context a reviewer can't get from the diff — skip file-by-file inventories, test result summaries, and anything obvious from the code itself. diff --git a/bun.lock b/bun.lock index 8e7efd9ccce..767e26bd302 100644 --- a/bun.lock +++ b/bun.lock @@ -187,7 +187,7 @@ "@clack/prompts": "1.0.0-alpha.1", "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", - "@openrouter/ai-sdk-provider": "2.3.3", + "@openrouter/ai-sdk-provider": "2.5.1", "ai": "catalog:", "open": "10.1.2", "zod": "catalog:", @@ -382,7 +382,7 @@ "@openauthjs/openauth": "catalog:", "@opencode-ai/script": "workspace:*", "@opencode-ai/util": "workspace:*", - "@openrouter/ai-sdk-provider": "2.3.3", + "@openrouter/ai-sdk-provider": "2.5.1", "@opentui/core": "0.1.96", "@opentui/solid": "0.1.96", "@parcel/watcher": "2.5.1", @@ -413,6 +413,7 @@ "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "minimatch": "10.2.5", + "npm-package-arg": "13.0.2", "open": "10.1.2", "opencode-gitlab-auth": "2.0.1", "opencode-poe-auth": "0.0.1", @@ -457,6 +458,7 @@ "@types/bun": "catalog:", "@types/cross-spawn": "catalog:", "@types/mime-types": "3.0.1", + "@types/npm-package-arg": "6.1.4", "@types/npmcli__arborist": "6.3.3", "@types/semver": "^7.5.8", "@types/turndown": "5.0.5", @@ -1485,7 +1487,7 @@ "@opencode-ai/util": ["@opencode-ai/util@workspace:packages/util"], - "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.3.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-4fVteGkVedc7fGoA9+qJs4tpYwALezMq14m2Sjub3KmyRlksCbK+WJf67NPdGem8+NZrV2tAN42A1NU3+SiV3w=="], + "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.5.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-r1fJL1Cb3gQDa2MpWH/sfx1BsEW0uzlRriJM6eihaKqbtKDmZoBisF32VcVaQYassighX7NGCkF68EsrZA43uQ=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], @@ -5079,6 +5081,8 @@ "ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.74", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HDDLsT+QrzE3c2QZLRV/HKAwMtXDb0PMDdk1PYUXLJ3r9Qv76zGKGyvJLX7Pu6c8TOHD1mwLrOVYrsTpC/eTMw=="], + "ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.3.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-4fVteGkVedc7fGoA9+qJs4tpYwALezMq14m2Sjub3KmyRlksCbK+WJf67NPdGem8+NZrV2tAN42A1NU3+SiV3w=="], + "ajv-keywords/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], "app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="], diff --git a/nix/hashes.json b/nix/hashes.json index 3589e47ff79..8d6f5f694d3 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-QozPRHFapCgdinNYAc2Yk+8sk/w7lJN2StBSjeXh5Rc=", - "aarch64-linux": "sha256-da239x4X+qs1EFGKJnSZu1bsqRVbKQVOvOYzzORokyI=", - "aarch64-darwin": "sha256-vp+jfpceTQjalxYIUh1ewxDis8mcTJNh6phbyHn/ucA=", - "x86_64-darwin": "sha256-cQgLcQGTUizoMIZ+KMj9HNEmogL84bJYakWGlXxWvkk=" + "x86_64-linux": "sha256-wRgRi939a/0RPCKthLccnBjYbiRcWhCOhbHd0HgyUhs=", + "aarch64-linux": "sha256-oYqoGtXLp9/GE5yU9l6RLoK8sVLxZKgp1BMcpmDhFe0=", + "aarch64-darwin": "sha256-zTpY9iM/Vp/OmQ35rybhRgoN7neXzNeugZD+0a5O+LU=", + "x86_64-darwin": "sha256-H/5W9FKEK/N9m11+N0sIZgr9LMQuhKaErrxYyQBEhtM=" } } diff --git a/package.json b/package.json index 040a44805dc..cf104473a3e 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,7 @@ "random": "echo 'Random script'", "hello": "echo 'Hello World!'", "test": "echo 'do not run tests from root' && exit 1", - "extension": "bun --cwd packages/kilo-vscode script/launch.ts", - "changeset": "bunx changeset", - "changeset:version": "bunx changeset version && bun install --lockfile-only" + "extension": "bun --cwd packages/kilo-vscode script/launch.ts" }, "workspaces": { "packages": [ @@ -76,8 +74,6 @@ }, "devDependencies": { "@actions/artifact": "5.0.1", - "@changesets/changelog-github": "^0.5.1", - "@changesets/cli": "^2.27.10", "@tsconfig/bun": "catalog:", "@types/mime-types": "3.0.1", "@typescript/native-preview": "catalog:", @@ -86,7 +82,9 @@ "prettier": "3.6.2", "semver": "^7.6.0", "sst": "3.18.10", - "turbo": "2.8.13" + "turbo": "2.8.13", + "@changesets/changelog-github": "^0.5.1", + "@changesets/cli": "^2.27.10" }, "dependencies": { "@aws-sdk/client-s3": "3.1025.0", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 6204ce037a3..2bd6ea940e6 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -398,7 +398,8 @@ export const dict = { "toast.session.unshare.failed.description": "Une erreur s'est produite lors de l'annulation du partage de la session", "toast.session.listFailed.title": "Échec du chargement des sessions pour {{project}}", "toast.update.title": "Mise à jour disponible", - "toast.update.description": "Une nouvelle version d'Kilo ({{version}}) est maintenant disponible pour installation.", + "toast.update.description": + "Une nouvelle version d'Kilo ({{version}}) est maintenant disponible pour installation.", "toast.update.action.installRestart": "Installer et redémarrer", "toast.update.action.notYet": "Pas encore", "error.page.title": "Quelque chose s'est mal passé", @@ -548,7 +549,8 @@ export const dict = { "sidebar.workspaces.enable": "Activer les espaces de travail", "sidebar.workspaces.disable": "Désactiver les espaces de travail", "sidebar.gettingStarted.title": "Commencer", - "sidebar.gettingStarted.line1": "Kilo inclut des modèles gratuits pour que vous puissiez commencer immédiatement.", + "sidebar.gettingStarted.line1": + "Kilo inclut des modèles gratuits pour que vous puissiez commencer immédiatement.", "sidebar.gettingStarted.line2": "Connectez n'importe quel fournisseur pour utiliser des modèles, y compris Claude, GPT, Gemini etc.", "sidebar.project.recentSessions": "Sessions récentes", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 0c22707e639..4a6bffdcbb5 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -149,7 +149,8 @@ export const dict = { "provider.connect.oauth.code.invalid": "รหัสการอนุญาตไม่ถูกต้อง", "provider.connect.oauth.auto.visit.prefix": "เยี่ยมชม ", "provider.connect.oauth.auto.visit.link": "ลิงก์นี้", - "provider.connect.oauth.auto.visit.suffix": " และป้อนรหัสด้านล่างเพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน Kilo", + "provider.connect.oauth.auto.visit.suffix": + " และป้อนรหัสด้านล่างเพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน Kilo", "provider.connect.oauth.auto.confirmationCode": "รหัสยืนยัน", "provider.connect.toast.connected.title": "{{provider}} ที่เชื่อมต่อแล้ว", "provider.connect.toast.connected.description": "โมเดล {{provider}} พร้อมใช้งานแล้ว", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index b405880a0c3..dcd680c9b93 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -150,7 +150,8 @@ export const dict = { "provider.connect.oauth.code.invalid": "授權碼無效", "provider.connect.oauth.auto.visit.prefix": "造訪 ", "provider.connect.oauth.auto.visit.link": "此連結", - "provider.connect.oauth.auto.visit.suffix": " 並輸入以下程式碼,以連線你的帳戶並在 Kilo 中使用 {{provider}} 模型。", + "provider.connect.oauth.auto.visit.suffix": + " 並輸入以下程式碼,以連線你的帳戶並在 Kilo 中使用 {{provider}} 模型。", "provider.connect.oauth.auto.confirmationCode": "確認碼", "provider.connect.toast.connected.title": "{{provider}} 已連線", "provider.connect.toast.connected.description": "現在可以使用 {{provider}} 模型了。", diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index f0ec9e1f5e1..f0fc50b1007 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -44,7 +44,9 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
- + - @@ -59,8 +59,6 @@ - -- - - - @@ -85,7 +83,7 @@ - - - + - - diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index a3f1d2bf3e3..9dba987edff 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -32,7 +32,7 @@ "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", "@ai-sdk/openai-compatible": "2.0.37", - "@openrouter/ai-sdk-provider": "2.3.3", + "@openrouter/ai-sdk-provider": "2.5.1", "@clack/prompts": "1.0.0-alpha.1", "ai": "catalog:", "open": "10.1.2", diff --git a/packages/kilo-gateway/src/api/models.ts b/packages/kilo-gateway/src/api/models.ts index fb86344075d..e5d9dacb5e1 100644 --- a/packages/kilo-gateway/src/api/models.ts +++ b/packages/kilo-gateway/src/api/models.ts @@ -1,6 +1,6 @@ import { z } from "zod" import { getKiloUrlFromToken } from "../auth/token.js" -import { getDefaultHeaders } from "../headers.js" +import { getDefaultHeaders, buildKiloHeaders } from "../headers.js" import { KILO_API_BASE, KILO_OPENROUTER_BASE, MODELS_FETCH_TIMEOUT_MS, PROMPTS, AI_SDK_PROVIDERS } from "./constants.js" /** @@ -89,6 +89,7 @@ export async function fetchKiloModels(options?: { const response = await fetch(modelsURL, { headers: { ...getDefaultHeaders(), + ...buildKiloHeaders(undefined, { kilocodeOrganizationId: organizationId }), ...(token ? { Authorization: `Bearer ${token}` } : {}), }, signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS), diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts index 44e36e3a3e3..6a79aa01ec3 100644 --- a/packages/kilo-gateway/src/server/routes.ts +++ b/packages/kilo-gateway/src/server/routes.ts @@ -32,6 +32,7 @@ interface KiloRoutesDeps extends ImportDeps { Auth: Auth ModelCache: ModelCache z: Z + Instance: ImportDeps["Instance"] & { disposeAll(): Promise } } /** @@ -206,6 +207,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { ModelCache.clear("kilo") clearModesCache() + await Instance.disposeAll() return c.json(true) }, diff --git a/packages/kilo-vscode/script/launch.ts b/packages/kilo-vscode/script/launch.ts index 7d86faeda4d..ea321c1f848 100644 --- a/packages/kilo-vscode/script/launch.ts +++ b/packages/kilo-vscode/script/launch.ts @@ -303,6 +303,13 @@ async function launch() { args.push("--wait") } + // Strip Electron/VS Code env vars so the spawned instance doesn't attach + // to the current Electron process (e.g. when launched from a VS Code task). + const env = { ...process.env } + for (const key of Object.keys(env)) { + if (key.startsWith("ELECTRON_") || key.startsWith("VSCODE_")) delete env[key] + } + console.log(`[launch] Starting VS Code (${mode} mode)`) console.log(`[launch] Executable: ${app}`) console.log(`[launch] Workspace: ${workspace}`) @@ -311,7 +318,7 @@ async function launch() { if (blocking) { const result = Bun.spawnSync([app, ...args], { cwd: workspace, - env: process.env, + env, stdio: ["ignore", "inherit", "inherit"], }) console.log(`[launch] VS Code exited (code ${result.exitCode})`) @@ -321,7 +328,7 @@ async function launch() { const child = spawn(app, args, { cwd: workspace, detached: !win, - env: process.env, + env, stdio: "ignore", ...(win ? { shell: true } : {}), }) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index df1cfb714a2..1b02b444b39 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -44,7 +44,8 @@ import { getBusySessionCount, seedSessionStatuses } from "./session-status" import { retry } from "./services/cli-backend/retry" import { slimPart, slimParts } from "./kilo-provider/slim-metadata" import { handleContinueInWorktree } from "./kilo-provider/continue-worktree" -import { parseMessageFiles } from "./kilo-provider/message-files" +import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-files" +import { getTerminalContents } from "./services/terminal/context" import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session" import { childID } from "./kilo-provider/task-session" import { handleNetworkEvent, clearNetworkWaits } from "./kilo-provider/network" @@ -121,6 +122,7 @@ const mapAgent = (a: Agent) => ({ color: a.color, deprecated: a.deprecated, permission: a.permission, + model: a.model, }) export class KiloProvider implements vscode.WebviewViewProvider, TelemetryPropertiesProvider { @@ -399,7 +401,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.visibilityDisposable?.dispose() this.visibilityDisposable = webviewView.onDidChangeVisibility(() => { vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", webviewView.visible) - this.statsPoller?.setEnabled(webviewView.visible) + if (this.statsPoller) { + this.statsPoller.setEnabled(webviewView.visible) + this.statsPoller.setVisible(webviewView.visible) + } this.focusSession(webviewView.visible ? this.currentSession?.id : undefined) }) this.initializeConnection() @@ -831,15 +836,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } break } + case "requestTerminalContext": + void this.handleTerminalContext(message.requestId) + break case "chatCompletionAccepted": this.chatAutocomplete?.telemetry.captureAcceptSuggestion(message.suggestionLength) break - case "deleteSession": - await this.handleDeleteSession(message.sessionID) - break - case "renameSession": - await this.handleRenameSession(message.sessionID, message.title) - break case "toggleRemote": case "setRemoteEnabled": case "requestRemoteStatus": @@ -850,6 +852,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) .catch((err) => console.error("[Kilo New] remote message failed:", err)) break + case "deleteSession": + await this.handleDeleteSession(message.sessionID) + break + case "renameSession": + await this.handleRenameSession(message.sessionID, message.title) + break case "updateSetting": await this.handleUpdateSetting(message.key, message.value) break @@ -1488,6 +1496,25 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.pendingSessionRefresh = ctx.pendingSessionRefresh } + private async handleTerminalContext(requestId: string): Promise { + try { + const output = await getTerminalContents(-1) + this.postMessage({ + type: "terminalContextResult", + requestId, + content: output.content, + truncated: output.truncated, + }) + } catch (error) { + console.error("[Kilo New] Failed to capture terminal context:", error) + this.postMessage({ + type: "terminalContextError", + requestId, + error: getErrorMessage(error) || "Failed to capture terminal output", + }) + } + } + /** * Handle deleting a session. */ @@ -2394,7 +2421,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper modelID?: string, agent?: string, variant?: string, - files?: Array<{ mime: string; url: string }>, + files?: MessageFile[], ): Promise { if (!this.client) { this.postMessage({ @@ -2416,7 +2443,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const parts: Array = [] if (files) { for (const f of files) { - parts.push({ type: "file", mime: f.mime, url: f.url }) + parts.push({ type: "file", mime: f.mime, url: f.url, filename: f.filename, source: f.source }) } } parts.push({ type: "text", text }) @@ -2470,7 +2497,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper modelID?: string, agent?: string, variant?: string, - files?: Array<{ mime: string; url: string }>, + files?: MessageFile[], ): Promise { if (!this.client) { this.postMessage({ @@ -2493,7 +2520,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.connectionService.recordMessageSessionId(messageID, resolved!.sid) } - const parts = files?.map((f) => ({ type: "file" as const, mime: f.mime, url: f.url })) + const parts = files?.map((f) => ({ + type: "file" as const, + mime: f.mime, + url: f.url, + filename: f.filename, + source: f.source, + })) const sid = resolved!.sid const dir = resolved!.dir @@ -3287,8 +3320,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage(msg) }, log: () => {}, + hiddenIntervalMs: 60000, }) this.statsPoller.setEnabled(true) + this.statsPoller.setVisible(true) } /** diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 3b9cb463b21..382c7fe8ef6 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -188,6 +188,11 @@ export class AgentManagerProvider implements Disposable { } this.panel = ctx + this.statsPoller.setVisible(ctx.visible) + ctx.onDidChangeVisibility((visible) => { + this.statsPoller.setVisible(visible) + }) + this.stateReady = this.initializeState() void this.sendRepoInfo() this.sendKeybindings() @@ -326,6 +331,11 @@ export class AgentManagerProvider implements Disposable { return msg } + if (m.type === "requestTerminalContext") { + if (m.sessionID) this.terminalManager.showExisting(m.sessionID) + return msg + } + if (m.type === "loadMessages") { this.activeSessionId = m.sessionID this.connectionService.registerFocused("agent-manager", m.sessionID) diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index 3f36edb587e..66c74cb464b 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -67,6 +67,9 @@ export class GitOps { private readonly runGit: (args: string[], cwd: string) => Promise private readonly controller = new AbortController() private readonly semaphore: Semaphore | undefined + private readonly resolutionCache = new Map() + private static readonly CACHE_TTL_MS = 60000 + private static readonly MAX_CACHE_SIZE = 100 get disposed(): boolean { return this.controller.signal.aborted @@ -87,6 +90,30 @@ export class GitOps { if (!this.controller.signal.aborted) { this.controller.abort() } + this.resolutionCache.clear() + } + + private getCached(key: string): string | undefined { + const entry = this.resolutionCache.get(key) + if (entry && entry.expires > Date.now()) { + return entry.value + } + return undefined + } + + private setCached(key: string, value: string): void { + if (this.resolutionCache.size >= GitOps.MAX_CACHE_SIZE) { + let oldestKey: string | undefined + let oldestExpiry = Infinity + for (const [k, v] of this.resolutionCache) { + if (v.expires < oldestExpiry) { + oldestExpiry = v.expires + oldestKey = k + } + } + if (oldestKey) this.resolutionCache.delete(oldestKey) + } + this.resolutionCache.set(key, { value, expires: Date.now() + GitOps.CACHE_TTL_MS }) } private raw(args: string[], cwd: string): Promise { @@ -122,38 +149,68 @@ export class GitOps { * 3. Falls back to `origin` */ async resolveRemote(cwd: string, branch?: string): Promise { + const cacheKey = `remote:${cwd}:${branch}` + const cached = this.getCached(cacheKey) + if (cached) return cached + const upstream = await this.raw(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], cwd).catch( () => "", ) - if (upstream.includes("/")) return upstream.split("/")[0] + if (upstream.includes("/")) { + const result = upstream.split("/")[0] + this.setCached(cacheKey, result) + return result + } const name = branch || (await this.raw(["branch", "--show-current"], cwd).catch(() => "")) if (name) { const configured = await this.raw(["config", `branch.${name}.remote`], cwd).catch(() => "") - if (configured) return configured + if (configured) { + this.setCached(cacheKey, configured) + return configured + } } - return "origin" + const result = "origin" + this.setCached(cacheKey, result) + return result } /** Resolve the upstream tracking ref for `branch`, or `undefined` if none is set. Note: the `@{upstream}` check uses the current HEAD, not `branch`. */ async resolveTrackingBranch(cwd: string, branch: string): Promise { + const cacheKey = `tracking:${cwd}:${branch}` + const cached = this.getCached(cacheKey) + if (cached !== undefined) return cached === "" ? undefined : cached + const upstream = await this.raw(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd).catch(() => "") - if (upstream) return upstream + if (upstream) { + this.setCached(cacheKey, upstream) + return upstream + } const remote = await this.resolveRemote(cwd, branch) const ref = `${remote}/${branch}` const resolved = await this.raw(["rev-parse", "--verify", ref], cwd).catch(() => "") - if (resolved) return ref + if (resolved) { + this.setCached(cacheKey, ref) + return ref + } + this.setCached(cacheKey, "") return undefined } /** Resolve the repo's default branch via /HEAD. */ async resolveDefaultBranch(cwd: string, branch?: string): Promise { const remote = await this.resolveRemote(cwd, branch) + const cacheKey = `default-branch:${cwd}:${remote}` + const cached = this.getCached(cacheKey) + if (cached !== undefined) return cached + const head = await this.raw(["symbolic-ref", "--short", `refs/remotes/${remote}/HEAD`], cwd).catch(() => "") - return head || undefined + const result = head || undefined + this.setCached(cacheKey, result ?? "") + return result } async hasRemoteRef(cwd: string, ref: string): Promise { diff --git a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts index 2cde458d432..255b0162f41 100644 --- a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts @@ -48,6 +48,7 @@ interface GitStatsPollerOptions { intervalMs?: number /** Shared concurrency gate for child process spawning. */ semaphore?: Semaphore + hiddenIntervalMs?: number } export class GitStatsPoller { @@ -62,14 +63,27 @@ export class GitStatsPoller { { files: number; additions: number; deletions: number; ahead: number; behind: number } > = {} private readonly intervalMs: number + private readonly hiddenIntervalMs: number private readonly git: GitOps private skipWorktreeIds = new Set() + private visible = true constructor(private readonly options: GitStatsPollerOptions) { this.intervalMs = options.intervalMs ?? 5000 + this.hiddenIntervalMs = options.hiddenIntervalMs ?? 60000 this.git = options.git } + setVisible(visible: boolean): void { + if (this.visible === visible) return + this.visible = visible + if (this.active && this.timer) { + clearTimeout(this.timer) + this.timer = undefined + this.schedule(this.visible ? this.intervalMs : this.hiddenIntervalMs) + } + } + skipWorktree(id: string): void { this.skipWorktreeIds.add(id) } @@ -81,7 +95,8 @@ export class GitStatsPoller { setEnabled(enabled: boolean): void { if (enabled) { if (this.active) return - this.start() + this.active = true + void this.poll() return } this.stop() @@ -100,10 +115,8 @@ export class GitStatsPoller { this.lastStats = {} } - private start(): void { - this.stop() - this.active = true - void this.poll() + private currentInterval(): number { + return this.visible ? this.intervalMs : this.hiddenIntervalMs } private schedule(delay: number): void { @@ -119,7 +132,7 @@ export class GitStatsPoller { this.busy = true return this.fetch().finally(() => { this.busy = false - this.schedule(this.intervalMs) + this.schedule(this.currentInterval()) }) } diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 43b7d19943d..2ea0840bbd1 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -534,6 +534,16 @@ interface LoadMessagesIn { sessionID: string } +interface FileSourceIn { + type: "file" + path: string + text: { + value: string + start: number + end: number + } +} + interface SendMessageIn { type: "sendMessage" text: string @@ -544,7 +554,7 @@ interface SendMessageIn { modelID?: string agent?: string variant?: string - files?: Array<{ mime: string; url: string; filename?: string }> + files?: Array<{ mime: string; url: string; filename?: string; source?: FileSourceIn }> } interface SendCommandIn { @@ -558,7 +568,13 @@ interface SendCommandIn { modelID?: string agent?: string variant?: string - files?: Array<{ mime: string; url: string; filename?: string }> + files?: Array<{ mime: string; url: string; filename?: string; source?: FileSourceIn }> +} + +interface RequestTerminalContextIn { + type: "requestTerminalContext" + requestId: string + sessionID?: string } interface ClearSessionIn { @@ -673,6 +689,7 @@ export type AgentManagerInMessage = | LoadMessagesIn | SendMessageIn | SendCommandIn + | RequestTerminalContextIn | ClearSessionIn | AbortIn | ContinueInWorktreeIn diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index 461905c7c8e..223f1b86d1e 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -206,10 +206,9 @@ export async function loadSessions(ctx: SessionRefreshContext): Promise s.id)) for (const batch of extra) { for (const s of batch) { - if (!seen.has(s.id) && (!projectID || s.projectID === projectID)) { - sessions.push(s) - seen.add(s.id) - } + if (seen.has(s.id)) continue + sessions.push(s) + seen.add(s.id) } } @@ -218,7 +217,7 @@ export async function loadSessions(ctx: SessionRefreshContext): Promise sessionToWebview(s)), }) - return sessions[0]?.projectID + return projectID } /** diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts index acb03659915..29ce097953d 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts @@ -8,6 +8,7 @@ import type { KiloClient, Session, TextPartInput, FilePartInput } from "@kilocode/sdk/v2/client" import type { CloudSessionData, EditorContext } from "../../services/cli-backend/types" import { getErrorMessage, sessionToWebview, mapCloudSessionMessageToWebviewMessage } from "../../kilo-provider-utils" +import type { MessageFile } from "../message-files" export interface CloudSessionContext { readonly client: KiloClient | null @@ -115,7 +116,7 @@ export async function handleImportAndSend( modelID?: string, agent?: string, variant?: string, - files?: Array<{ mime: string; url: string }>, + files?: MessageFile[], command?: string, commandArgs?: string, ): Promise { @@ -177,7 +178,13 @@ export async function handleImportAndSend( } if (command) { - const parts = files?.map((f) => ({ type: "file" as const, mime: f.mime, url: f.url })) + const parts = files?.map((f) => ({ + type: "file" as const, + mime: f.mime, + url: f.url, + filename: f.filename, + source: f.source, + })) await client.session.command( { sessionID: session.id, @@ -198,7 +205,7 @@ export async function handleImportAndSend( const parts: Array = [] if (files) { for (const f of files) { - parts.push({ type: "file", mime: f.mime, url: f.url }) + parts.push({ type: "file", mime: f.mime, url: f.url, filename: f.filename, source: f.source }) } } parts.push({ type: "text", text }) diff --git a/packages/kilo-vscode/src/kilo-provider/message-files.ts b/packages/kilo-vscode/src/kilo-provider/message-files.ts index 35b419d419f..108fd3ef809 100644 --- a/packages/kilo-vscode/src/kilo-provider/message-files.ts +++ b/packages/kilo-vscode/src/kilo-provider/message-files.ts @@ -1,11 +1,24 @@ import { z } from "zod" +const source = z.object({ + type: z.literal("file"), + path: z.string(), + text: z.object({ + value: z.string(), + start: z.number(), + end: z.number(), + }), +}) + const file = z.object({ mime: z.string(), url: z.string().refine((url) => url.startsWith("file://") || url.startsWith("data:")), filename: z.string().optional(), + source: source.optional(), }) +export type MessageFile = z.infer + export function parseMessageFiles(value: unknown) { return z.array(file).optional().catch(undefined).parse(value) } diff --git a/packages/kilo-vscode/src/services/code-actions/register-terminal-actions.ts b/packages/kilo-vscode/src/services/code-actions/register-terminal-actions.ts index 1f64de85533..b04a7db5a95 100644 --- a/packages/kilo-vscode/src/services/code-actions/register-terminal-actions.ts +++ b/packages/kilo-vscode/src/services/code-actions/register-terminal-actions.ts @@ -2,52 +2,7 @@ import * as vscode from "vscode" import type { KiloProvider } from "../../KiloProvider" import type { AgentManagerProvider } from "../../agent-manager/AgentManagerProvider" import { createPrompt } from "./support-prompt" - -/** - * Read terminal content via clipboard. - * When `commands` is negative, selects all terminal content. - * When positive, selects the last N commands. - */ -async function getTerminalContents(commands = -1): Promise { - const saved = await vscode.env.clipboard.readText() - - try { - if (commands < 0) { - await vscode.commands.executeCommand("workbench.action.terminal.selectAll") - } else { - for (let i = 0; i < commands; i++) { - await vscode.commands.executeCommand("workbench.action.terminal.selectToPreviousCommand") - } - } - - await vscode.commands.executeCommand("workbench.action.terminal.copySelection") - await vscode.commands.executeCommand("workbench.action.terminal.clearSelection") - - let content = (await vscode.env.clipboard.readText()).trim() - - await vscode.env.clipboard.writeText(saved) - - if (saved === content) { - return "" - } - - // Trim duplicate trailing prompt line - const lines = content.split("\n") - const last = lines.pop()?.trim() - if (last) { - let i = lines.length - 1 - while (i >= 0 && !lines[i].trim().startsWith(last)) { - i-- - } - content = lines.slice(Math.max(i, 0)).join("\n") - } - - return content - } catch (err) { - await vscode.env.clipboard.writeText(saved) - throw err - } -} +import { getTerminalContents } from "../terminal/context" export function registerTerminalActions( context: vscode.ExtensionContext, @@ -60,7 +15,7 @@ export function registerTerminalActions( vscode.commands.registerCommand("kilo-code.new.terminalAddToContext", async (args: any) => { let content = args?.selection as string | undefined if (!content) { - content = await getTerminalContents(-1) + content = (await getTerminalContents(-1)).content } if (!content) { vscode.window.showInformationMessage("No terminal content available. Select text in the terminal first.") @@ -77,7 +32,7 @@ export function registerTerminalActions( vscode.commands.registerCommand("kilo-code.new.terminalFixCommand", async (args: any) => { let content = args?.selection as string | undefined if (!content) { - content = await getTerminalContents(1) + content = (await getTerminalContents(1)).content } if (!content) { vscode.window.showInformationMessage("No terminal content available. Select text in the terminal first.") @@ -93,7 +48,7 @@ export function registerTerminalActions( vscode.commands.registerCommand("kilo-code.new.terminalExplainCommand", async (args: any) => { let content = args?.selection as string | undefined if (!content) { - content = await getTerminalContents(1) + content = (await getTerminalContents(1)).content } if (!content) { vscode.window.showInformationMessage("No terminal content available. Select text in the terminal first.") diff --git a/packages/kilo-vscode/src/services/terminal/context.ts b/packages/kilo-vscode/src/services/terminal/context.ts new file mode 100644 index 00000000000..854835172d9 --- /dev/null +++ b/packages/kilo-vscode/src/services/terminal/context.ts @@ -0,0 +1,39 @@ +import * as vscode from "vscode" +import { truncateTerminalOutput, type TerminalLimitOptions, type TerminalOutput } from "./truncate" + +function trimPrompt(content: string) { + const lines = content.split("\n") + const last = lines.pop()?.trim() + if (!last) return content + + const idx = lines.reduce((found, line, index) => (line.trim().startsWith(last) ? index : found), -1) + return lines.slice(Math.max(idx, 0)).join("\n") +} + +async function selectPrevious(count: number): Promise { + if (count <= 0) return + await vscode.commands.executeCommand("workbench.action.terminal.selectToPreviousCommand") + await selectPrevious(count - 1) +} + +export async function getTerminalContents(commands = -1, opts?: TerminalLimitOptions): Promise { + const saved = await vscode.env.clipboard.readText() + + try { + if (commands < 0) { + await vscode.commands.executeCommand("workbench.action.terminal.selectAll") + } else { + await selectPrevious(commands) + } + + await vscode.commands.executeCommand("workbench.action.terminal.copySelection") + await vscode.commands.executeCommand("workbench.action.terminal.clearSelection") + + const copied = (await vscode.env.clipboard.readText()).trim() + if (saved === copied) return { content: "", truncated: false } + + return truncateTerminalOutput(trimPrompt(copied), opts) + } finally { + await vscode.env.clipboard.writeText(saved) + } +} diff --git a/packages/kilo-vscode/src/services/terminal/truncate.ts b/packages/kilo-vscode/src/services/terminal/truncate.ts new file mode 100644 index 00000000000..643b7a6567a --- /dev/null +++ b/packages/kilo-vscode/src/services/terminal/truncate.ts @@ -0,0 +1,39 @@ +export const TERMINAL_OUTPUT_LINE_LIMIT = 500 +export const TERMINAL_OUTPUT_CHARACTER_LIMIT = 50_000 + +export type TerminalLimitOptions = { + lineLimit?: number + characterLimit?: number +} + +export type TerminalOutput = { + content: string + truncated: boolean +} + +export function truncateTerminalOutput(content: string, opts: TerminalLimitOptions = {}): TerminalOutput { + const chars = opts.characterLimit ?? TERMINAL_OUTPUT_CHARACTER_LIMIT + if (chars > 0 && content.length > chars) { + const before = Math.floor(chars * 0.2) + const after = chars - before + const omitted = content.length - chars + return { + content: `${content.slice(0, before)}\n[...${omitted} characters omitted...]\n${content.slice(-after)}`, + truncated: true, + } + } + + const limit = opts.lineLimit ?? TERMINAL_OUTPUT_LINE_LIMIT + if (limit <= 0) return { content, truncated: false } + + const lines = content.split("\n") + if (lines.length <= limit) return { content, truncated: false } + + const before = Math.floor(limit * 0.2) + const after = limit - before + const omitted = lines.length - limit + return { + content: `${lines.slice(0, before).join("\n")}\n\n[...${omitted} lines omitted...]\n\n${lines.slice(-after).join("\n")}`, + truncated: true, + } +} diff --git a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts index 597290f5198..56e7d31d0c5 100644 --- a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts @@ -4,6 +4,7 @@ import { syncMentionedPaths, buildTextAfterMentionSelect, buildFileAttachments, + buildMentionResults, } from "../../webview-ui/src/hooks/file-mention-utils" describe("AT_PATTERN", () => { @@ -29,6 +30,28 @@ describe("AT_PATTERN", () => { }) }) +describe("buildMentionResults", () => { + it("includes terminal for empty mention query", () => { + const result = buildMentionResults("", []) + expect(result[0]).toEqual({ + type: "terminal", + value: "terminal", + label: "Terminal", + description: "Active terminal output", + }) + }) + + it("includes terminal for matching prefix", () => { + const result = buildMentionResults("term", ["src/terminal.ts"]) + expect(result.map((item) => item.type)).toEqual(["terminal", "file"]) + }) + + it("omits terminal for unrelated query", () => { + const result = buildMentionResults("src", ["src/index.ts"]) + expect(result.map((item) => item.type)).toEqual(["file"]) + }) +}) + describe("syncMentionedPaths", () => { it("keeps paths still referenced in text", () => { const paths = new Set(["foo.ts", "bar.ts"]) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts index 9704a172d4a..7747f980adb 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts @@ -93,6 +93,70 @@ function createConnection(client: ReturnType) { } describe("KiloProvider pending session refresh", () => { + it("keeps worktree sessions with legacy project ids", async () => { + const sent: unknown[] = [] + const ctx = createContext({ + connectionState: "connected", + sessionDirectories: new Map([["ses_worktree", "/worktree"]]), + listSessions: async (dir) => { + if (dir === "/repo") { + return [ + { + id: "ses_root", + projectID: "project-new", + title: "root", + directory: "/repo", + time: { created: 1, updated: 1 }, + }, + ] as never + } + return [ + { + id: "ses_worktree", + projectID: "project-old", + title: "worktree", + directory: "/worktree", + time: { created: 2, updated: 2 }, + }, + ] as never + }, + postMessage: (msg) => sent.push(msg), + }) + + const project = await loadSessions(ctx) + + expect(project).toBe("project-new") + expect(sent).toHaveLength(1) + expect((sent[0] as { sessions: { id: string }[] }).sessions.map((s) => s.id)).toEqual(["ses_root", "ses_worktree"]) + }) + + it("does not use legacy worktree sessions as canonical project", async () => { + const sent: unknown[] = [] + const ctx = createContext({ + connectionState: "connected", + sessionDirectories: new Map([["ses_worktree", "/worktree"]]), + listSessions: async (dir) => { + if (dir === "/repo") return [] as never + return [ + { + id: "ses_worktree", + projectID: "project-old", + title: "worktree", + directory: "/worktree", + time: { created: 2, updated: 2 }, + }, + ] as never + }, + postMessage: (msg) => sent.push(msg), + }) + + const project = await loadSessions(ctx) + + expect(project).toBeUndefined() + expect(sent).toHaveLength(1) + expect((sent[0] as { sessions: { id: string }[] }).sessions.map((s) => s.id)).toEqual(["ses_worktree"]) + }) + it("flushes deferred refresh via flushPendingSessionRefresh", async () => { const { calls, fn } = createListSessions() const ctx = createContext() diff --git a/packages/kilo-vscode/tests/unit/message-files.test.ts b/packages/kilo-vscode/tests/unit/message-files.test.ts new file mode 100644 index 00000000000..106008353d4 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/message-files.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "bun:test" +import { parseMessageFiles } from "../../src/kilo-provider/message-files" + +describe("parseMessageFiles", () => { + it("accepts terminal text attachments with source metadata", () => { + const files = parseMessageFiles([ + { + mime: "text/plain", + url: "data:text/plain;charset=utf-8,terminal%20output", + filename: "terminal-output.txt", + source: { + type: "file", + path: "terminal-output.txt", + text: { value: "@terminal", start: 0, end: 9 }, + }, + }, + ]) + + expect(files?.[0]?.filename).toBe("terminal-output.txt") + expect(files?.[0]?.source?.text.value).toBe("@terminal") + }) + + it("rejects unsupported URLs", () => { + expect(parseMessageFiles([{ mime: "text/plain", url: "https://example.com/file.txt" }])).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/terminal-architecture.test.ts b/packages/kilo-vscode/tests/unit/terminal-architecture.test.ts new file mode 100644 index 00000000000..b7f760b843a --- /dev/null +++ b/packages/kilo-vscode/tests/unit/terminal-architecture.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "bun:test" +import fs from "node:fs" +import path from "node:path" + +const ROOT = path.resolve(import.meta.dir, "../..") +const src = (file: string) => fs.readFileSync(path.join(ROOT, file), "utf-8") + +describe("terminal context architecture", () => { + it("keeps VS Code terminal command capture in the terminal service", () => { + const helper = src("src/services/terminal/context.ts") + const provider = src("src/KiloProvider.ts") + const actions = src("src/services/code-actions/register-terminal-actions.ts") + + expect(helper).toContain("workbench.action.terminal.selectAll") + expect(provider).not.toContain("workbench.action.terminal.selectAll") + expect(actions).not.toContain("workbench.action.terminal.selectAll") + }) + + it("keeps webview terminal attachment logic outside PromptInput", () => { + const prompt = src("webview-ui/src/components/chat/PromptInput.tsx") + const hook = src("webview-ui/src/hooks/useTerminalContext.ts") + const util = src("webview-ui/src/hooks/terminal-context-utils.ts") + + expect(prompt).toContain("useTerminalContext") + expect(prompt).not.toContain("requestTerminalContext") + expect(prompt).not.toContain("data:text/plain") + expect(hook).toContain("requestTerminalContext") + expect(util).toContain("data:text/plain") + }) + + it("keeps terminal output limits in the shared truncation helper", () => { + const helper = src("src/services/terminal/truncate.ts") + const provider = src("src/KiloProvider.ts") + const prompt = src("webview-ui/src/components/chat/PromptInput.tsx") + + expect(helper).toContain("TERMINAL_OUTPUT_LINE_LIMIT = 500") + expect(helper).toContain("TERMINAL_OUTPUT_CHARACTER_LIMIT = 50_000") + expect(provider).not.toContain("TERMINAL_OUTPUT_LINE_LIMIT") + expect(prompt).not.toContain("TERMINAL_OUTPUT_LINE_LIMIT") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/terminal-context-utils.test.ts b/packages/kilo-vscode/tests/unit/terminal-context-utils.test.ts new file mode 100644 index 00000000000..1b40840980d --- /dev/null +++ b/packages/kilo-vscode/tests/unit/terminal-context-utils.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "bun:test" +import { + buildTerminalAttachment, + findTerminalMention, + hasTerminalMention, +} from "../../webview-ui/src/hooks/terminal-context-utils" + +describe("terminal context utils", () => { + it("detects standalone terminal mentions", () => { + expect(hasTerminalMention("see @terminal output")).toBe(true) + expect(hasTerminalMention("see foo@terminal output")).toBe(false) + expect(hasTerminalMention("see @terminal-output")).toBe(false) + }) + + it("returns mention source range", () => { + expect(findTerminalMention("hello @terminal")!).toEqual({ value: "@terminal", start: 6, end: 15 }) + }) + + it("builds a text attachment with source metadata", () => { + const attachment = buildTerminalAttachment("check @terminal", "npm failed")! + expect(attachment.mime).toBe("text/plain") + expect(attachment.filename).toBe("terminal-output.txt") + expect(attachment.url).toBe("data:text/plain;charset=utf-8,npm%20failed") + expect(attachment.source?.text).toEqual({ value: "@terminal", start: 6, end: 15 }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/terminal-truncate.test.ts b/packages/kilo-vscode/tests/unit/terminal-truncate.test.ts new file mode 100644 index 00000000000..95fdf4dd918 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/terminal-truncate.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test" +import { truncateTerminalOutput } from "../../src/services/terminal/truncate" + +describe("truncateTerminalOutput", () => { + it("returns content within limits", () => { + expect(truncateTerminalOutput("one\ntwo", { lineLimit: 5, characterLimit: 100 })).toEqual({ + content: "one\ntwo", + truncated: false, + }) + }) + + it("truncates by character limit first", () => { + const result = truncateTerminalOutput("a".repeat(20), { lineLimit: 1, characterLimit: 10 }) + expect(result.truncated).toBe(true) + expect(result.content).toContain("[...10 characters omitted...]") + }) + + it("truncates by line limit", () => { + const result = truncateTerminalOutput("1\n2\n3\n4\n5", { lineLimit: 3, characterLimit: 100 }) + expect(result.truncated).toBe(true) + expect(result.content).toContain("[...2 lines omitted...]") + expect(result.content.endsWith("3\n4\n5")).toBe(true) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx index d03615c2542..bf20c69fdea 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx @@ -145,10 +145,14 @@ export const PermissionDock: Component<{ } } - // Auto-focus the dock when it appears so keyboard shortcuts work immediately + // Keep keyboard shortcuts when the webview already has focus, but do not + // steal focus from the editor, terminal, or other VS Code surfaces. createEffect(() => { void props.request.id - requestAnimationFrame(() => root?.focus()) + requestAnimationFrame(() => { + if (!document.hasFocus()) return + root?.focus() + }) }) return ( diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 3b5738cf61d..b5a2ff32182 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -9,6 +9,8 @@ import { Dialog } from "@kilocode/kilo-ui/dialog" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { FileIcon } from "@kilocode/kilo-ui/file-icon" +import { Icon } from "@kilocode/kilo-ui/icon" +import { showToast } from "@kilocode/kilo-ui/toast" import { useDialog } from "@kilocode/kilo-ui/context/dialog" import { useSession } from "../../context/session" import { useServer } from "../../context/server" @@ -19,6 +21,8 @@ import { ModelSelector } from "../shared/ModelSelector" import { ModeSwitcher } from "../shared/ModeSwitcher" import { ThinkingSelector } from "../shared/ThinkingSelector" import { useFileMention } from "../../hooks/useFileMention" +import { useTerminalContext } from "../../hooks/useTerminalContext" +import { hasTerminalMention } from "../../hooks/terminal-context-utils" import { useSlashCommand } from "../../hooks/useSlashCommand" import { useGhostText } from "../../hooks/useGhostText" import { useImageAttachments, type ImageAttachment } from "../../hooks/useImageAttachments" @@ -58,6 +62,7 @@ export const PromptInput: Component = (props) => { const worktree = useWorktreeMode() const dialog = useDialog() const mention = useFileMention(vscode) + const terminal = useTerminalContext(vscode) const excluded = worktree ? new Set(["sessions"]) : undefined const slash = useSlashCommand(vscode, excluded) const imageAttach = useImageAttachments() @@ -266,10 +271,15 @@ export const PromptInput: Component = (props) => { const isBusy = () => session.status() !== "idle" const isDisabled = () => !server.isConnected() const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0 - const canSend = () => hasInput() && !isDisabled() && !props.blocked?.() + const canSend = () => hasInput() && !isDisabled() && !terminal.pending() && !props.blocked?.() const showStop = () => isBusy() && !hasInput() const isAtEnd = () => textareaRef ? atEnd(textareaRef.selectionStart, textareaRef.selectionEnd, textareaRef.value.length) : false + const highlightMentions = () => { + const paths = new Set(mention.mentionedPaths()) + if (hasTerminalMention(text())) paths.add("terminal") + return paths + } const placeholder = () => { switch (server.connectionState()) { case "connecting": @@ -309,7 +319,7 @@ export const PromptInput: Component = (props) => { const merged = mergeReviewComments(reviewComments(), message.comments) replaceReviewComments(merged) if (message.autoSend && empty && !isDisabled() && !props.blocked?.()) { - handleSend() + void handleSend() } else { textareaRef?.focus() } @@ -567,7 +577,7 @@ export const PromptInput: Component = (props) => { vscode.postMessage({ type: "enhancePrompt", text: draft, requestId: `enhance-${draftKey()}-${enhanceCounter}` }) } - const handleSend = () => { + const handleSend = async () => { const draft = text().trim() // Detect slash command (hoisted for both client and server command checks). @@ -598,17 +608,24 @@ export const PromptInput: Component = (props) => { const pending = reviewComments() const review = pending.length > 0 ? formatReviewCommentsMarkdown(pending) : "" const message = draft && review ? `${review}\n\n${draft}` : draft || review - if ((!message && imgs.length === 0) || isDisabled() || props.blocked?.()) return + if ((!message && imgs.length === 0) || isDisabled() || terminal.pending() || props.blocked?.()) return const mentionFiles = mention.parseFileAttachments(draft) const imgFiles = imgs.map((img) => ({ mime: img.mime, url: img.dataUrl, filename: img.filename })) - const allFiles = [...mentionFiles, ...imgFiles] - const sel = session.selected() + const pendingId = props.pendingSessionID ?? session.draftSessionID() + const sid = session.currentSessionID() + + const terminalFile = await terminal.resolveAttachment(message, sid).catch((err: Error) => { + showToast({ variant: "error", title: "Terminal context unavailable", description: err.message }) + return undefined + }) + if (hasTerminalMention(message) && !terminalFile) return + + const allFiles = [...mentionFiles, ...imgFiles, ...(terminalFile ? [terminalFile] : [])] const attachments = allFiles.length > 0 ? allFiles : undefined const key = draftKey() - const pendingId = props.pendingSessionID ?? session.draftSessionID() // Server-side slash command (cmdMatch/matched already computed above) if (matched) { const rest = draft.slice(cmdMatch![0].length).trim() @@ -697,19 +714,29 @@ export const PromptInput: Component = (props) => { fallback={
No files found
} > - {(path, index) => ( + {(item, index) => (
{ e.preventDefault() - if (textareaRef) mention.selectFile(path, textareaRef, setText, adjustHeight) + if (textareaRef) mention.selectMention(item, textareaRef, setText, adjustHeight) }} onMouseEnter={() => mention.setMentionIndex(index())} > - - {fileName(path)} - {dirName(path)} + {item.type === "terminal" ? ( + <> + + {item.label} + {item.description} + + ) : ( + <> + + {fileName(item.value)} + {dirName(item.value)} + + )}
)}
@@ -806,7 +833,7 @@ export const PromptInput: Component = (props) => {